From 69372a49ed255306d76ff380dd755d01f6033cd1 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Fri, 14 Aug 2026 12:45:37 +0200 Subject: [PATCH] feat: history and calculate needs for user --- app/Actions/AcceptWeeklyReviewAdjustment.php | 79 ++++++ app/Actions/CompleteNutritionOnboarding.php | 28 +++ app/Actions/RecalculateNutritionPlan.php | 87 +++++++ app/Enums/NutritionPlanSource.php | 10 + app/Enums/WeeklyReviewStatus.php | 10 + .../Resources/Users/Schemas/UserForm.php | 76 ------ .../Resources/Users/Schemas/UserInfolist.php | 47 ++-- .../Resources/Users/Tables/UsersTable.php | 20 +- app/Http/Controllers/AuthController.php | 66 ++--- .../NutritionOnboardingController.php | 19 ++ .../Controllers/NutritionPlanController.php | 24 ++ .../NutritionProfileController.php | 22 ++ .../Controllers/WeeklyReviewController.php | 39 +++ .../Controllers/WeightEntryController.php | 55 +++++ .../EnsureNutritionOnboardingCompleted.php | 26 ++ .../CompleteNutritionOnboardingRequest.php | 82 +++++++ .../Requests/ListWeightEntriesRequest.php | 28 +++ app/Http/Requests/RegisterRequest.php | 9 - app/Http/Requests/StoreWeightEntryRequest.php | 29 +++ .../UpdateNutritionProfileRequest.php | 14 ++ app/Http/Requests/UpdateUserRequest.php | 29 +-- app/Http/Resources/NutritionPlanResource.php | 36 +++ app/Http/Resources/UserResource.php | 32 ++- app/Http/Resources/WeeklyReviewResource.php | 42 ++++ app/Http/Resources/WeightEntryResource.php | 27 +++ app/Models/NutritionPlan.php | 65 +++++ app/Models/User.php | 47 +++- app/Models/WeeklyReview.php | 73 ++++++ app/Models/WeightEntry.php | 38 +++ app/Services/NutritionPlanCalculator.php | 128 ++++++++++ app/Services/WeeklyReviewService.php | 227 ++++++++++++++++++ bootstrap/app.php | 86 ++++++- database/factories/NutritionPlanFactory.php | 41 ++++ database/factories/UserFactory.php | 80 +++++- database/factories/WeeklyReviewFactory.php | 43 ++++ database/factories/WeightEntryFactory.php | 27 +++ .../0001_01_01_000000_create_users_table.php | 19 +- ...14_075205_create_nutrition_plans_table.php | 44 ++++ ..._14_075205_create_weight_entries_table.php | 33 +++ ..._14_075206_create_weekly_reviews_table.php | 49 ++++ lang/en/admin.php | 13 +- lang/en/api.php | 29 ++- lang/fr/admin.php | 13 +- lang/fr/api.php | 29 ++- routes/api.php | 39 ++- tests/Feature/EmailVerificationTest.php | 48 ++-- tests/Feature/NutritionOnboardingTest.php | 93 +++++++ tests/Feature/UserNutritionGoalsTest.php | 83 ++++--- tests/Feature/WeeklyReviewTest.php | 132 ++++++++++ tests/Feature/WeightEntryTest.php | 46 ++++ tests/Unit/NutritionPlanCalculatorTest.php | 70 ++++++ 51 files changed, 2226 insertions(+), 305 deletions(-) create mode 100644 app/Actions/AcceptWeeklyReviewAdjustment.php create mode 100644 app/Actions/CompleteNutritionOnboarding.php create mode 100644 app/Actions/RecalculateNutritionPlan.php create mode 100644 app/Enums/NutritionPlanSource.php create mode 100644 app/Enums/WeeklyReviewStatus.php create mode 100644 app/Http/Controllers/NutritionOnboardingController.php create mode 100644 app/Http/Controllers/NutritionPlanController.php create mode 100644 app/Http/Controllers/NutritionProfileController.php create mode 100644 app/Http/Controllers/WeeklyReviewController.php create mode 100644 app/Http/Controllers/WeightEntryController.php create mode 100644 app/Http/Middleware/EnsureNutritionOnboardingCompleted.php create mode 100644 app/Http/Requests/CompleteNutritionOnboardingRequest.php create mode 100644 app/Http/Requests/ListWeightEntriesRequest.php create mode 100644 app/Http/Requests/StoreWeightEntryRequest.php create mode 100644 app/Http/Requests/UpdateNutritionProfileRequest.php create mode 100644 app/Http/Resources/NutritionPlanResource.php create mode 100644 app/Http/Resources/WeeklyReviewResource.php create mode 100644 app/Http/Resources/WeightEntryResource.php create mode 100644 app/Models/NutritionPlan.php create mode 100644 app/Models/WeeklyReview.php create mode 100644 app/Models/WeightEntry.php create mode 100644 app/Services/NutritionPlanCalculator.php create mode 100644 app/Services/WeeklyReviewService.php create mode 100644 database/factories/NutritionPlanFactory.php create mode 100644 database/factories/WeeklyReviewFactory.php create mode 100644 database/factories/WeightEntryFactory.php create mode 100644 database/migrations/2026_08_14_075205_create_nutrition_plans_table.php create mode 100644 database/migrations/2026_08_14_075205_create_weight_entries_table.php create mode 100644 database/migrations/2026_08_14_075206_create_weekly_reviews_table.php create mode 100644 tests/Feature/NutritionOnboardingTest.php create mode 100644 tests/Feature/WeeklyReviewTest.php create mode 100644 tests/Feature/WeightEntryTest.php create mode 100644 tests/Unit/NutritionPlanCalculatorTest.php diff --git a/app/Actions/AcceptWeeklyReviewAdjustment.php b/app/Actions/AcceptWeeklyReviewAdjustment.php new file mode 100644 index 0000000..98f32a1 --- /dev/null +++ b/app/Actions/AcceptWeeklyReviewAdjustment.php @@ -0,0 +1,79 @@ +lockForUpdate()->findOrFail($weeklyReview->getKey()); + + abort_if($lockedReview->accepted_at !== null, 409, 'WEEKLY_ADJUSTMENT_ALREADY_ACCEPTED'); + abort_unless( + $lockedReview->status === WeeklyReviewStatus::ADJUSTMENT_RECOMMENDED + && $lockedReview->recommended_calorie_adjustment !== 0, + 409, + 'WEEKLY_ADJUSTMENT_UNAVAILABLE', + ); + + $user = $lockedReview->user()->lockForUpdate()->firstOrFail(); + $currentPlan = $user->nutritionPlans()->where('is_active', true)->latest('effective_from')->firstOrFail(); + + abort_unless( + $lockedReview->nutrition_plan_id === $currentPlan->getKey(), + 409, + 'WEEKLY_ADJUSTMENT_STALE', + ); + + $now = now(); + $targetCalories = max(1200, $currentPlan->calories + $lockedReview->recommended_calorie_adjustment); + $proteins = (int) round($currentPlan->proteins); + $fats = (int) round($currentPlan->fats); + $carbs = max(0, (int) round(($targetCalories - ($proteins * 4) - ($fats * 9)) / 4)); + $calories = ($proteins * 4) + ($carbs * 4) + ($fats * 9); + + abort_if( + $calories === $currentPlan->calories, + 409, + 'WEEKLY_ADJUSTMENT_UNAVAILABLE', + ); + + $currentPlan->update([ + 'is_active' => false, + 'effective_until' => $now, + ]); + + $nextPlan = $user->nutritionPlans()->create([ + 'source' => NutritionPlanSource::WEEKLY_ADJUSTMENT, + 'formula_version' => $currentPlan->formula_version, + 'calories' => $calories, + 'proteins' => $proteins, + 'carbs' => $carbs, + 'fats' => $fats, + 'resting_metabolism' => $currentPlan->resting_metabolism, + 'maintenance_calories' => $currentPlan->maintenance_calories, + 'calorie_adjustment_percent' => ($calories - $currentPlan->maintenance_calories) / max(1, $currentPlan->maintenance_calories), + 'calculation_details' => [ + ...$currentPlan->calculation_details, + 'weeklyReviewId' => $lockedReview->getKey(), + 'weeklyAdjustmentCalories' => $lockedReview->recommended_calorie_adjustment, + ], + 'effective_from' => $now, + ]); + + $lockedReview->update([ + 'accepted_at' => $now, + 'accepted_nutrition_plan_id' => $nextPlan->getKey(), + ]); + + return $nextPlan; + }, attempts: 3); + } +} diff --git a/app/Actions/CompleteNutritionOnboarding.php b/app/Actions/CompleteNutritionOnboarding.php new file mode 100644 index 0000000..cc7489f --- /dev/null +++ b/app/Actions/CompleteNutritionOnboarding.php @@ -0,0 +1,28 @@ + $data + */ + public function execute(User $user, array $data): User + { + if ($user->hasCompletedNutritionOnboarding()) { + return $user->loadMissing(['activeNutritionPlan', 'latestWeightEntry']); + } + + return $this->recalculateNutritionPlan->execute( + user: $user, + data: $data, + source: NutritionPlanSource::ONBOARDING, + completeOnboarding: true, + ); + } +} diff --git a/app/Actions/RecalculateNutritionPlan.php b/app/Actions/RecalculateNutritionPlan.php new file mode 100644 index 0000000..98b8460 --- /dev/null +++ b/app/Actions/RecalculateNutritionPlan.php @@ -0,0 +1,87 @@ + $data + */ + public function execute( + User $user, + array $data, + NutritionPlanSource $source, + bool $completeOnboarding = false, + ): User { + return DB::transaction(function () use ($user, $data, $source, $completeOnboarding): User { + $lockedUser = User::query()->lockForUpdate()->findOrFail($user->getKey()); + $now = now(); + + if ($completeOnboarding && $lockedUser->hasCompletedNutritionOnboarding()) { + return $lockedUser->load(['activeNutritionPlan', 'latestWeightEntry']); + } + + $lockedUser->update([ + 'height' => $data['heightCm'], + 'target_weight' => $data['targetWeightKg'] ?? null, + 'date_of_birth' => $data['dateOfBirth'], + 'sex' => $data['sex'], + 'physical_activity_level' => $data['physicalActivityLevel'], + 'weight_goal' => $data['weightGoal'], + 'pace_preference' => $data['pacePreference'], + ...($completeOnboarding ? [ + 'onboarding_completed_at' => $now, + 'nutrition_estimate_accepted_at' => $now, + ] : []), + ]); + + $weightKg = (float) $data['weightKg']; + $latestWeight = $lockedUser->weightEntries()->latest('measured_at')->first(); + + if ($latestWeight === null || abs($latestWeight->weight_kg - $weightKg) >= 0.01) { + $lockedUser->weightEntries()->create([ + 'weight_kg' => $weightKg, + 'measured_at' => $now, + ]); + } + + $lockedUser->nutritionPlans() + ->where('is_active', true) + ->update([ + 'is_active' => false, + 'effective_until' => $now, + ]); + + $calculation = $this->calculator->calculate( + weightKg: $weightKg, + heightCm: (int) $data['heightCm'], + dateOfBirth: CarbonImmutable::parse($data['dateOfBirth']), + sex: UserSex::from($data['sex']), + activityLevel: PhysicalActivityLevel::from($data['physicalActivityLevel']), + weightGoal: WeightGoal::from($data['weightGoal']), + pacePreference: PacePreference::from($data['pacePreference']), + ); + + $lockedUser->nutritionPlans()->create([ + ...$calculation, + 'source' => $source, + 'is_active' => true, + 'effective_from' => $now, + ]); + + return $lockedUser->fresh()->load(['activeNutritionPlan', 'latestWeightEntry']); + }, attempts: 3); + } +} diff --git a/app/Enums/NutritionPlanSource.php b/app/Enums/NutritionPlanSource.php new file mode 100644 index 0000000..b1c4b21 --- /dev/null +++ b/app/Enums/NutritionPlanSource.php @@ -0,0 +1,10 @@ +label(__('admin.users.fields.email_verified_at')), ]), Section::make(__('admin.users.sections.profile')) - ->columns(2) ->schema([ Textarea::make('bio') ->label(__('admin.users.fields.bio')) ->rows(4) ->columnSpanFull(), - TextInput::make('height') - ->label(__('admin.users.fields.height')) - ->integer() - ->minValue(0) - ->maxValue(300) - ->suffix('cm'), - DatePicker::make('date_of_birth') - ->label(__('admin.users.fields.date_of_birth')) - ->minDate('1900-01-01') - ->maxDate(now()->subDay()), - Select::make('sex') - ->label(__('admin.users.fields.sex')) - ->options(UserSex::class) - ->default(UserSex::UNKNOWN->value) - ->required(), - ]), - Section::make(__('admin.users.sections.nutrition_goals')) - ->columns(4) - ->schema([ - TextInput::make('daily_calorie_goal') - ->label(__('admin.users.fields.daily_calorie_goal')) - ->integer() - ->minValue(0) - ->maxValue(100000) - ->suffix('kcal') - ->default(2000) - ->required(), - TextInput::make('daily_protein_goal') - ->label(__('admin.users.fields.daily_protein_goal')) - ->numeric() - ->minValue(0) - ->maxValue(10000) - ->suffix('g') - ->default(120) - ->required(), - TextInput::make('daily_carbs_goal') - ->label(__('admin.users.fields.daily_carbs_goal')) - ->numeric() - ->minValue(0) - ->maxValue(10000) - ->suffix('g') - ->default(250) - ->required(), - TextInput::make('daily_fats_goal') - ->label(__('admin.users.fields.daily_fats_goal')) - ->numeric() - ->minValue(0) - ->maxValue(10000) - ->suffix('g') - ->default(70) - ->required(), - ]), - Section::make(__('admin.users.sections.activity')) - ->columns(3) - ->schema([ - Select::make('physical_activity_level') - ->label(__('admin.users.fields.physical_activity_level')) - ->options(PhysicalActivityLevel::class) - ->default(PhysicalActivityLevel::SEDENTARY->value) - ->required(), - Select::make('weight_goal') - ->label(__('admin.users.fields.weight_goal')) - ->options(WeightGoal::class) - ->default(WeightGoal::LOSE_WEIGHT->value) - ->required(), - Select::make('pace_preference') - ->label(__('admin.users.fields.pace_preference')) - ->options(PacePreference::class) - ->default(PacePreference::SLOW->value) - ->required(), ]), ]); } diff --git a/app/Filament/Resources/Users/Schemas/UserInfolist.php b/app/Filament/Resources/Users/Schemas/UserInfolist.php index 52a9703..49a4e14 100644 --- a/app/Filament/Resources/Users/Schemas/UserInfolist.php +++ b/app/Filament/Resources/Users/Schemas/UserInfolist.php @@ -67,6 +67,16 @@ class UserInfolist ->numeric(decimalPlaces: 0) ->suffix(' cm') ->placeholder(__('admin.users.placeholders.empty')), + TextEntry::make('latestWeightEntry.weight_kg') + ->label(__('admin.users.fields.current_weight')) + ->numeric(maxDecimalPlaces: 2) + ->suffix(' kg') + ->placeholder(__('admin.users.placeholders.empty')), + TextEntry::make('target_weight') + ->label(__('admin.users.fields.target_weight')) + ->numeric(maxDecimalPlaces: 2) + ->suffix(' kg') + ->placeholder(__('admin.users.placeholders.empty')), TextEntry::make('date_of_birth') ->label(__('admin.users.fields.date_of_birth')) ->date() @@ -75,25 +85,29 @@ class UserInfolist ->label(__('admin.users.fields.sex')) ->badge(), ]), - Section::make(__('admin.users.sections.nutrition_goals')) + Section::make(__('admin.users.sections.nutrition_plan')) ->columns(4) ->schema([ - TextEntry::make('daily_calorie_goal') - ->label(__('admin.users.fields.daily_calorie_goal')) + TextEntry::make('activeNutritionPlan.calories') + ->label(__('admin.users.fields.calorie_goal')) ->numeric(decimalPlaces: 0) - ->suffix(' kcal'), - TextEntry::make('daily_protein_goal') - ->label(__('admin.users.fields.daily_protein_goal')) + ->suffix(' kcal') + ->placeholder(__('admin.users.placeholders.empty')), + TextEntry::make('activeNutritionPlan.proteins') + ->label(__('admin.users.fields.protein_goal')) ->numeric(maxDecimalPlaces: 2) - ->suffix(' g'), - TextEntry::make('daily_carbs_goal') - ->label(__('admin.users.fields.daily_carbs_goal')) + ->suffix(' g') + ->placeholder(__('admin.users.placeholders.empty')), + TextEntry::make('activeNutritionPlan.carbs') + ->label(__('admin.users.fields.carbs_goal')) ->numeric(maxDecimalPlaces: 2) - ->suffix(' g'), - TextEntry::make('daily_fats_goal') - ->label(__('admin.users.fields.daily_fats_goal')) + ->suffix(' g') + ->placeholder(__('admin.users.placeholders.empty')), + TextEntry::make('activeNutritionPlan.fats') + ->label(__('admin.users.fields.fats_goal')) ->numeric(maxDecimalPlaces: 2) - ->suffix(' g'), + ->suffix(' g') + ->placeholder(__('admin.users.placeholders.empty')), ]), Section::make(__('admin.users.sections.activity')) ->columns(3) @@ -106,7 +120,12 @@ class UserInfolist ->badge(), TextEntry::make('pace_preference') ->label(__('admin.users.fields.pace_preference')) - ->badge(), + ->badge() + ->placeholder(__('admin.users.placeholders.empty')), + TextEntry::make('onboarding_completed_at') + ->label(__('admin.users.fields.onboarding_completed_at')) + ->dateTime() + ->placeholder(__('admin.users.placeholders.empty')), ]), Section::make(__('admin.users.sections.metadata')) ->columns(2) diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php index e0b5e35..5ef714a 100644 --- a/app/Filament/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -95,29 +95,25 @@ class UsersTable ->badge() ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('daily_calorie_goal') - ->label(__('admin.users.fields.daily_calorie_goal')) + TextColumn::make('activeNutritionPlan.calories') + ->label(__('admin.users.fields.calorie_goal')) ->numeric(decimalPlaces: 0) ->suffix(' kcal') - ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('daily_protein_goal') - ->label(__('admin.users.fields.daily_protein_goal')) + TextColumn::make('activeNutritionPlan.proteins') + ->label(__('admin.users.fields.protein_goal')) ->numeric(maxDecimalPlaces: 2) ->suffix(' g') - ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('daily_carbs_goal') - ->label(__('admin.users.fields.daily_carbs_goal')) + TextColumn::make('activeNutritionPlan.carbs') + ->label(__('admin.users.fields.carbs_goal')) ->numeric(maxDecimalPlaces: 2) ->suffix(' g') - ->sortable() ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('daily_fats_goal') - ->label(__('admin.users.fields.daily_fats_goal')) + TextColumn::make('activeNutritionPlan.fats') + ->label(__('admin.users.fields.fats_goal')) ->numeric(maxDecimalPlaces: 2) ->suffix(' g') - ->sortable() ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('bio') ->label(__('admin.users.fields.bio')) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 66e1d08..24c1f3b 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -43,7 +43,7 @@ class AuthController extends Controller $this->startWebSession($request, $user); return response()->json([ - 'message' => __('api.auth.registered_unverified'), + 'code' => 'REGISTERED_UNVERIFIED', 'user' => new UserResource($user), ], 201); } @@ -73,7 +73,7 @@ class AuthController extends Controller } return response()->json([ - 'message' => __('api.auth.registered_unverified'), + 'code' => 'REGISTERED_UNVERIFIED', 'token' => $this->createMobileToken($user, $request->validated('device_name')), 'user' => new UserResource($user), ], 201); @@ -101,7 +101,7 @@ class AuthController extends Controller if (! hash_equals($hash, sha1($user->getEmailForVerification()))) { if ($request->expectsJson()) { return response()->json([ - 'message' => __('api.auth.invalid_verification_link'), + 'code' => 'INVALID_VERIFICATION_LINK', ], 403); } @@ -118,9 +118,9 @@ class AuthController extends Controller if ($request->expectsJson()) { return response()->json([ - 'message' => $alreadyVerified - ? __('api.auth.already_verified') - : __('api.auth.verified'), + 'code' => $alreadyVerified + ? 'EMAIL_ALREADY_VERIFIED' + : 'EMAIL_VERIFIED', 'user' => new UserResource($user->fresh()), ]); } @@ -141,7 +141,7 @@ class AuthController extends Controller } return response()->json([ - 'message' => __('api.auth.verification_sent_if_unverified'), + 'code' => 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED', ]); } @@ -152,12 +152,12 @@ class AuthController extends Controller if ($status === Password::RESET_THROTTLED) { return response()->json([ - 'message' => __($status), + 'code' => 'PASSWORD_RESET_THROTTLED', ], 429); } return response()->json([ - 'message' => __('api.auth.password_reset_link_sent'), + 'code' => 'PASSWORD_RESET_LINK_SENT', ]); } @@ -167,12 +167,12 @@ class AuthController extends Controller if ($status !== Password::PASSWORD_RESET) { return response()->json([ - 'message' => __($status), + 'code' => 'PASSWORD_RESET_FAILED', ], 422); } return response()->json([ - 'message' => __('api.auth.password_reset'), + 'code' => 'PASSWORD_RESET', ]); } @@ -228,7 +228,7 @@ class AuthController extends Controller } return response()->json([ - 'message' => __('api.auth.logout'), + 'code' => 'LOGGED_OUT', ]); } @@ -237,7 +237,7 @@ class AuthController extends Controller $user = auth()->user(); if (! $user) { - return response()->json(['message' => __('api.auth.unauthenticated')], 401); + return response()->json(['code' => 'UNAUTHENTICATED'], 401); } return new UserResource($user); @@ -247,10 +247,9 @@ class AuthController extends Controller { $user = $request->user(); - abort_if($user->isSuspended(), 403, __('api.auth.suspended')); + abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED'); $data = $request->validated(); - $nutritionGoals = $data['nutritionGoals'] ?? null; if ($request->hasFile('avatar')) { if ($user->avatar_url) { @@ -263,13 +262,6 @@ class AuthController extends Controller $userAttributes = $this->userAttributesFromRequestData($data); - if (is_array($nutritionGoals)) { - $userAttributes['daily_calorie_goal'] = $nutritionGoals['calories']; - $userAttributes['daily_protein_goal'] = $nutritionGoals['proteins']; - $userAttributes['daily_carbs_goal'] = $nutritionGoals['carbs']; - $userAttributes['daily_fats_goal'] = $nutritionGoals['fats']; - } - if (! empty($userAttributes)) { $user->update($userAttributes); } @@ -281,7 +273,7 @@ class AuthController extends Controller { $user = $request->user(); - abort_if($user->isSuspended(), 403, __('api.auth.suspended')); + abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED'); $currentAccessToken = $user->currentAccessToken(); @@ -299,7 +291,7 @@ class AuthController extends Controller } return response()->json([ - 'message' => __('api.auth.password_updated'), + 'code' => 'PASSWORD_UPDATED', ]); } @@ -350,7 +342,7 @@ class AuthController extends Controller } return response()->json([ - 'message' => __('api.auth.deleted'), + 'code' => 'ACCOUNT_DELETED', ]); } @@ -382,7 +374,7 @@ class AuthController extends Controller } return response()->json([ - 'message' => __('api.auth.verification_email_failed'), + 'code' => 'VERIFICATION_EMAIL_FAILED', ], 503); } @@ -398,13 +390,13 @@ class AuthController extends Controller if (! $user || ! Hash::check((string) $data['password'], $user->password)) { return response()->json([ - 'message' => __('api.auth.invalid_credentials'), + 'code' => 'INVALID_CREDENTIALS', ], 401); } if ($user->isSuspended()) { return response()->json([ - 'message' => __('api.auth.suspended'), + 'code' => 'ACCOUNT_SUSPENDED', ], 403); } @@ -414,7 +406,7 @@ class AuthController extends Controller if (! $user->hasVerifiedEmail()) { return response()->json([ - 'message' => __('api.auth.email_not_verified'), + 'code' => 'EMAIL_NOT_VERIFIED', ], 403); } @@ -480,25 +472,9 @@ class AuthController extends Controller 'name', 'avatar_url', 'locale', - 'height', 'bio', - 'sex', - 'date_of_birth', ]); - $attributeMap = [ - 'physicalActivityLevel' => 'physical_activity_level', - 'weightGoal' => 'weight_goal', - 'pacePreference' => 'pace_preference', - 'dateOfBirth' => 'date_of_birth', - ]; - - foreach ($attributeMap as $requestKey => $attribute) { - if (array_key_exists($requestKey, $data)) { - $attributes[$attribute] = $data[$requestKey]; - } - } - return $attributes; } } diff --git a/app/Http/Controllers/NutritionOnboardingController.php b/app/Http/Controllers/NutritionOnboardingController.php new file mode 100644 index 0000000..58afcbb --- /dev/null +++ b/app/Http/Controllers/NutritionOnboardingController.php @@ -0,0 +1,19 @@ +execute($request->user(), $request->validated()) + ); + } +} diff --git a/app/Http/Controllers/NutritionPlanController.php b/app/Http/Controllers/NutritionPlanController.php new file mode 100644 index 0000000..aab11a9 --- /dev/null +++ b/app/Http/Controllers/NutritionPlanController.php @@ -0,0 +1,24 @@ +user()->nutritionPlans()->latest('effective_from')->limit(50)->get() + ); + } + + public function current(Request $request): NutritionPlanResource + { + $plan = $request->user()->nutritionPlans()->where('is_active', true)->latest('effective_from')->firstOrFail(); + + return new NutritionPlanResource($plan); + } +} diff --git a/app/Http/Controllers/NutritionProfileController.php b/app/Http/Controllers/NutritionProfileController.php new file mode 100644 index 0000000..5849c65 --- /dev/null +++ b/app/Http/Controllers/NutritionProfileController.php @@ -0,0 +1,22 @@ +execute( + user: $request->user(), + data: $request->validated(), + source: NutritionPlanSource::PROFILE_RECALCULATION, + )); + } +} diff --git a/app/Http/Controllers/WeeklyReviewController.php b/app/Http/Controllers/WeeklyReviewController.php new file mode 100644 index 0000000..f039835 --- /dev/null +++ b/app/Http/Controllers/WeeklyReviewController.php @@ -0,0 +1,39 @@ +user()->weeklyReviews()->latest('period_start')->limit(52)->get() + ); + } + + public function current(Request $request, WeeklyReviewService $weeklyReviewService): JsonResponse + { + return (new WeeklyReviewResource($weeklyReviewService->generate($request->user()))) + ->response() + ->setStatusCode(200); + } + + public function accept( + Request $request, + WeeklyReview $weeklyReview, + AcceptWeeklyReviewAdjustment $acceptWeeklyReviewAdjustment, + ): NutritionPlanResource { + abort_unless($weeklyReview->user_id === $request->user()->getKey(), 404); + + return new NutritionPlanResource($acceptWeeklyReviewAdjustment->execute($weeklyReview)); + } +} diff --git a/app/Http/Controllers/WeightEntryController.php b/app/Http/Controllers/WeightEntryController.php new file mode 100644 index 0000000..d285cbb --- /dev/null +++ b/app/Http/Controllers/WeightEntryController.php @@ -0,0 +1,55 @@ +subDays($request->integer('days', 365))->startOfDay(); + $entries = $request->user() + ->weightEntries() + ->where('measured_at', '>=', $from) + ->latest('measured_at') + ->get(); + + return WeightEntryResource::collection($entries); + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreWeightEntryRequest $request): WeightEntryResource + { + $entry = $request->user()->weightEntries()->create([ + 'weight_kg' => $request->validated('weightKg'), + 'measured_at' => CarbonImmutable::parse($request->validated('measuredAt') ?? now()), + ]); + + return new WeightEntryResource($entry); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Request $request, WeightEntry $weightEntry): Response + { + abort_unless($weightEntry->user_id === $request->user()->getKey(), 404); + + $weightEntry->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Middleware/EnsureNutritionOnboardingCompleted.php b/app/Http/Middleware/EnsureNutritionOnboardingCompleted.php new file mode 100644 index 0000000..189acdd --- /dev/null +++ b/app/Http/Middleware/EnsureNutritionOnboardingCompleted.php @@ -0,0 +1,26 @@ +user()?->hasCompletedNutritionOnboarding()) { + return response()->json([ + 'code' => 'ONBOARDING_REQUIRED', + ], 409); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/CompleteNutritionOnboardingRequest.php b/app/Http/Requests/CompleteNutritionOnboardingRequest.php new file mode 100644 index 0000000..ec76b47 --- /dev/null +++ b/app/Http/Requests/CompleteNutritionOnboardingRequest.php @@ -0,0 +1,82 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'dateOfBirth' => [ + 'required', + 'date_format:Y-m-d', + 'before_or_equal:'.now()->subYears(18)->toDateString(), + 'after_or_equal:'.now()->subYears(100)->toDateString(), + ], + 'sex' => ['required', Rule::in([UserSex::MAN->value, UserSex::WOMAN->value])], + 'heightCm' => ['required', 'integer', 'min:120', 'max:230'], + 'weightKg' => ['required', 'numeric', 'min:35', 'max:300'], + 'targetWeightKg' => ['nullable', 'numeric', 'min:35', 'max:300'], + 'physicalActivityLevel' => ['required', Rule::enum(PhysicalActivityLevel::class)], + 'weightGoal' => ['required', Rule::enum(WeightGoal::class)], + 'pacePreference' => ['required', Rule::enum(PacePreference::class)], + 'nutritionEstimateAccepted' => ['required', 'accepted'], + ]; + } + + public function after(): array + { + return [ + function (Validator $validator): void { + $weight = $this->float('weightKg'); + $targetWeight = $this->input('targetWeightKg'); + $goal = $this->string('weightGoal')->toString(); + + if ($targetWeight === null || $targetWeight === '') { + return; + } + + $targetWeight = (float) $targetWeight; + + if ($goal === WeightGoal::LOSE_WEIGHT->value && $targetWeight >= $weight) { + $validator->errors()->add('targetWeightKg', 'NUTRITION_TARGET_WEIGHT_MUST_BE_LOWER'); + } + + if ($goal === WeightGoal::GAIN_WEIGHT->value && $targetWeight <= $weight) { + $validator->errors()->add('targetWeightKg', 'NUTRITION_TARGET_WEIGHT_MUST_BE_HIGHER'); + } + }, + ]; + } + + public function messages(): array + { + return [ + 'dateOfBirth.before_or_equal' => 'NUTRITION_ADULTS_ONLY', + 'dateOfBirth.after_or_equal' => 'NUTRITION_BIRTH_DATE_OUT_OF_RANGE', + 'sex.in' => 'NUTRITION_METABOLIC_SEX_REQUIRED', + 'nutritionEstimateAccepted.accepted' => 'NUTRITION_ESTIMATE_ACCEPTANCE_REQUIRED', + ]; + } +} diff --git a/app/Http/Requests/ListWeightEntriesRequest.php b/app/Http/Requests/ListWeightEntriesRequest.php new file mode 100644 index 0000000..0ac522d --- /dev/null +++ b/app/Http/Requests/ListWeightEntriesRequest.php @@ -0,0 +1,28 @@ +user()?->hasCompletedNutritionOnboarding() === true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'days' => ['sometimes', 'integer', 'min:7', 'max:3650'], + ]; + } +} diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php index 351ad31..aa2edfe 100644 --- a/app/Http/Requests/RegisterRequest.php +++ b/app/Http/Requests/RegisterRequest.php @@ -2,10 +2,6 @@ namespace App\Http\Requests; -use App\Enums\PacePreference; -use App\Enums\PhysicalActivityLevel; -use App\Enums\UserSex; -use App\Enums\WeightGoal; use App\Http\Requests\Concerns\ModeratesUserContent; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -56,11 +52,6 @@ class RegisterRequest extends FormRequest 'termsAccepted' => ['required', 'accepted'], 'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'], 'bio' => ['nullable', 'string'], - 'physicalActivityLevel' => ['sometimes', Rule::enum(PhysicalActivityLevel::class)], - 'weightGoal' => ['sometimes', Rule::enum(WeightGoal::class)], - 'pacePreference' => ['sometimes', Rule::enum(PacePreference::class)], - 'sex' => ['sometimes', Rule::enum(UserSex::class)], - 'dateOfBirth' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'before:today', 'after:1900-01-01'], ]; } diff --git a/app/Http/Requests/StoreWeightEntryRequest.php b/app/Http/Requests/StoreWeightEntryRequest.php new file mode 100644 index 0000000..4db28db --- /dev/null +++ b/app/Http/Requests/StoreWeightEntryRequest.php @@ -0,0 +1,29 @@ +user()?->hasCompletedNutritionOnboarding() === true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'weightKg' => ['required', 'numeric', 'min:35', 'max:300'], + 'measuredAt' => ['sometimes', 'nullable', 'date', 'before_or_equal:now'], + ]; + } +} diff --git a/app/Http/Requests/UpdateNutritionProfileRequest.php b/app/Http/Requests/UpdateNutritionProfileRequest.php new file mode 100644 index 0000000..96991ca --- /dev/null +++ b/app/Http/Requests/UpdateNutritionProfileRequest.php @@ -0,0 +1,14 @@ + [ + 'sometimes', + 'string', + 'min:3', + 'max:32', + 'regex:/^[a-zA-Z0-9_.-]+$/', + Rule::unique('users', 'name')->ignore($this->user()), + ], 'bio' => ['sometimes', 'nullable', 'string'], - 'height' => ['sometimes', 'nullable', 'integer'], 'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))], 'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'], - 'nutritionGoals' => ['sometimes', 'array'], - 'nutritionGoals.calories' => ['required_with:nutritionGoals', 'integer', 'min:0', 'max:100000'], - 'nutritionGoals.proteins' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'], - 'nutritionGoals.carbs' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'], - 'nutritionGoals.fats' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'], - 'physicalActivityLevel' => ['sometimes', Rule::enum(PhysicalActivityLevel::class)], - 'weightGoal' => ['sometimes', Rule::enum(WeightGoal::class)], - 'pacePreference' => ['sometimes', Rule::enum(PacePreference::class)], - 'sex' => ['sometimes', Rule::enum(UserSex::class)], - 'dateOfBirth' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'before:today', 'after:1900-01-01'], ]; } public function after(): array { return $this->moderationChecks( - [$this->input('bio')], + [$this->input('name'), $this->input('bio')], [$this->file('avatar')], ); } @@ -51,10 +44,6 @@ class UpdateUserRequest extends FormRequest { return [ 'avatar.max' => __('api.validation.image_max_2mb'), - 'nutritionGoals.calories.integer' => __('api.validation.calories_integer'), - 'nutritionGoals.*.required_with' => __('api.validation.nutrition_goals_required'), - 'nutritionGoals.*.min' => __('api.validation.nutrition_goals_positive'), - 'nutritionGoals.*.max' => __('api.validation.nutrition_goals_max'), ]; } diff --git a/app/Http/Resources/NutritionPlanResource.php b/app/Http/Resources/NutritionPlanResource.php new file mode 100644 index 0000000..45b770b --- /dev/null +++ b/app/Http/Resources/NutritionPlanResource.php @@ -0,0 +1,36 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'source' => $this->source, + 'formulaVersion' => $this->formula_version, + 'calories' => $this->calories, + 'proteins' => $this->proteins, + 'carbs' => $this->carbs, + 'fats' => $this->fats, + 'restingMetabolism' => $this->resting_metabolism, + 'maintenanceCalories' => $this->maintenance_calories, + 'calorieAdjustmentPercent' => $this->calorie_adjustment_percent, + 'calculationDetails' => $this->calculation_details, + 'effectiveFrom' => $this->effective_from?->toISOString(), + 'effectiveUntil' => $this->effective_until?->toISOString(), + 'isActive' => $this->is_active, + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index b7cf7c5..3044c93 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -15,14 +15,19 @@ class UserResource extends JsonResource */ public function toArray(Request $request): array { + $this->resource->loadMissing(['activeNutritionPlan', 'latestWeightEntry']); + $nutritionPlan = $this->activeNutritionPlan; + return [ - 'id' => $this->id, // à voir si suppression par la suite + 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, 'locale' => $this->locale, 'emailVerified' => $this->hasVerifiedEmail(), 'emailVerifiedAt' => $this->email_verified_at?->toISOString(), 'height' => $this->height, + 'currentWeight' => $this->latestWeightEntry?->weight_kg, + 'targetWeight' => $this->target_weight, 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, 'bio' => $this->bio, 'role' => $this->role, @@ -38,20 +43,27 @@ class UserResource extends JsonResource 'hasActiveSubscription' => $this->hasActiveSubscription(), 'suspendedAt' => $this->suspended_at?->toISOString(), 'physicalActivityLevel' => $this->physical_activity_level, - 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), 'weightGoal' => $this->weight_goal, - 'weightGoalLabel' => $this->weight_goal?->getLabel(), 'pacePreference' => $this->pace_preference, - 'pacePreferenceLabel' => $this->pace_preference?->getLabel(), 'sex' => $this->sex, - 'sexLabel' => $this->sex?->getLabel(), 'dateOfBirth' => $this->date_of_birth?->toDateString(), - 'nutritionGoals' => [ - 'calories' => (int) $this->daily_calorie_goal, - 'proteins' => (float) $this->daily_protein_goal, - 'carbs' => (float) $this->daily_carbs_goal, - 'fats' => (float) $this->daily_fats_goal, + 'onboardingStatus' => $this->hasCompletedNutritionOnboarding() ? 'completed' : 'required', + 'missingOnboardingFields' => $this->hasCompletedNutritionOnboarding() ? [] : [ + 'dateOfBirth', + 'sex', + 'heightCm', + 'weightKg', + 'physicalActivityLevel', + 'weightGoal', + 'pacePreference', ], + 'nutritionPlan' => NutritionPlanResource::make($nutritionPlan), + 'nutritionGoals' => $nutritionPlan ? [ + 'calories' => $nutritionPlan->calories, + 'proteins' => $nutritionPlan->proteins, + 'carbs' => $nutritionPlan->carbs, + 'fats' => $nutritionPlan->fats, + ] : null, ]; } } diff --git a/app/Http/Resources/WeeklyReviewResource.php b/app/Http/Resources/WeeklyReviewResource.php new file mode 100644 index 0000000..673cd29 --- /dev/null +++ b/app/Http/Resources/WeeklyReviewResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'periodStart' => $this->period_start?->toDateString(), + 'periodEnd' => $this->period_end?->toDateString(), + 'averages' => [ + 'calories' => $this->average_calories, + 'proteins' => $this->average_proteins, + 'carbs' => $this->average_carbs, + 'fats' => $this->average_fats, + ], + 'loggedDaysCount' => $this->logged_days_count, + 'weightEntriesCount' => $this->weight_entries_count, + 'weightTrendKg' => $this->weight_trend_kg, + 'targetWeightTrendKg' => $this->target_weight_trend_kg, + 'status' => $this->status, + 'recommendedCalorieAdjustment' => $this->recommended_calorie_adjustment, + 'messageCode' => $this->message_code, + 'metrics' => $this->metrics, + 'generatedAt' => $this->generated_at?->toISOString(), + 'acceptedAt' => $this->accepted_at?->toISOString(), + 'acceptedNutritionPlanId' => $this->accepted_nutrition_plan_id, + ]; + } +} diff --git a/app/Http/Resources/WeightEntryResource.php b/app/Http/Resources/WeightEntryResource.php new file mode 100644 index 0000000..1aea8b7 --- /dev/null +++ b/app/Http/Resources/WeightEntryResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'weightKg' => $this->weight_kg, + 'source' => $this->source, + 'measuredAt' => $this->measured_at?->toISOString(), + 'createdAt' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/app/Models/NutritionPlan.php b/app/Models/NutritionPlan.php new file mode 100644 index 0000000..269dc7d --- /dev/null +++ b/app/Models/NutritionPlan.php @@ -0,0 +1,65 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'source', + 'formula_version', + 'calories', + 'proteins', + 'carbs', + 'fats', + 'resting_metabolism', + 'maintenance_calories', + 'calorie_adjustment_percent', + 'calculation_details', + 'is_active', + 'effective_from', + 'effective_until', + ]; + + protected $attributes = [ + 'is_active' => true, + ]; + + protected function casts(): array + { + return [ + 'source' => NutritionPlanSource::class, + 'calories' => 'integer', + 'proteins' => 'float', + 'carbs' => 'float', + 'fats' => 'float', + 'resting_metabolism' => 'integer', + 'maintenance_calories' => 'integer', + 'calorie_adjustment_percent' => 'float', + 'calculation_details' => 'array', + 'is_active' => 'boolean', + 'effective_from' => 'immutable_datetime', + 'effective_until' => 'immutable_datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function weeklyReviews(): HasMany + { + return $this->hasMany(WeeklyReview::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2df04a4..f35d9a4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -51,6 +51,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'password', 'avatar_url', 'height', + 'target_weight', 'bio', 'account_verified_at', 'certification_purchased_at', @@ -58,15 +59,13 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'subscription_store', 'subscription_expires_at', 'subscription_is_trial', - 'daily_calorie_goal', - 'daily_protein_goal', - 'daily_carbs_goal', - 'daily_fats_goal', 'physical_activity_level', 'weight_goal', 'pace_preference', 'sex', 'date_of_birth', + 'onboarding_completed_at', + 'nutrition_estimate_accepted_at', 'terms_accepted_at', 'social_notifications_enabled', 'engagement_reminders_enabled', @@ -97,16 +96,16 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale 'account_verified_at' => 'datetime', 'certification_purchased_at' => 'datetime', 'password' => 'hashed', - 'daily_calorie_goal' => 'integer', - 'daily_protein_goal' => 'float', - 'daily_carbs_goal' => 'float', - 'daily_fats_goal' => 'float', + 'height' => 'integer', + 'target_weight' => 'float', 'physical_activity_level' => PhysicalActivityLevel::class, 'weight_goal' => WeightGoal::class, 'pace_preference' => PacePreference::class, 'sex' => UserSex::class, 'role' => UserRole::class, 'date_of_birth' => 'immutable_date', + 'onboarding_completed_at' => 'datetime', + 'nutrition_estimate_accepted_at' => 'datetime', 'terms_accepted_at' => 'datetime', 'social_notifications_enabled' => 'boolean', 'engagement_reminders_enabled' => 'boolean', @@ -146,6 +145,38 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->hasMany(WorkoutSessions::class); } + public function nutritionPlans(): HasMany + { + return $this->hasMany(NutritionPlan::class); + } + + public function activeNutritionPlan(): HasOne + { + return $this->hasOne(NutritionPlan::class) + ->where('is_active', true) + ->latestOfMany('effective_from'); + } + + public function weightEntries(): HasMany + { + return $this->hasMany(WeightEntry::class); + } + + public function latestWeightEntry(): HasOne + { + return $this->hasOne(WeightEntry::class)->latestOfMany('measured_at'); + } + + public function weeklyReviews(): HasMany + { + return $this->hasMany(WeeklyReview::class); + } + + public function hasCompletedNutritionOnboarding(): bool + { + return $this->onboarding_completed_at !== null; + } + public function stravaConnection(): HasOne { return $this->hasOne(StravaConnection::class); diff --git a/app/Models/WeeklyReview.php b/app/Models/WeeklyReview.php new file mode 100644 index 0000000..668341e --- /dev/null +++ b/app/Models/WeeklyReview.php @@ -0,0 +1,73 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'nutrition_plan_id', + 'period_start', + 'period_end', + 'average_calories', + 'average_proteins', + 'average_carbs', + 'average_fats', + 'logged_days_count', + 'weight_entries_count', + 'weight_trend_kg', + 'target_weight_trend_kg', + 'status', + 'recommended_calorie_adjustment', + 'message_code', + 'metrics', + 'generated_at', + 'accepted_at', + 'accepted_nutrition_plan_id', + ]; + + protected function casts(): array + { + return [ + 'period_start' => 'immutable_date', + 'period_end' => 'immutable_date', + 'average_calories' => 'float', + 'average_proteins' => 'float', + 'average_carbs' => 'float', + 'average_fats' => 'float', + 'logged_days_count' => 'integer', + 'weight_entries_count' => 'integer', + 'weight_trend_kg' => 'float', + 'target_weight_trend_kg' => 'float', + 'status' => WeeklyReviewStatus::class, + 'recommended_calorie_adjustment' => 'integer', + 'metrics' => 'array', + 'generated_at' => 'immutable_datetime', + 'accepted_at' => 'immutable_datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function nutritionPlan(): BelongsTo + { + return $this->belongsTo(NutritionPlan::class); + } + + public function acceptedNutritionPlan(): BelongsTo + { + return $this->belongsTo(NutritionPlan::class, 'accepted_nutrition_plan_id'); + } +} diff --git a/app/Models/WeightEntry.php b/app/Models/WeightEntry.php new file mode 100644 index 0000000..2abe211 --- /dev/null +++ b/app/Models/WeightEntry.php @@ -0,0 +1,38 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'weight_kg', + 'source', + 'measured_at', + ]; + + protected $attributes = [ + 'source' => 'manual', + ]; + + protected function casts(): array + { + return [ + 'weight_kg' => 'float', + 'measured_at' => 'immutable_datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/NutritionPlanCalculator.php b/app/Services/NutritionPlanCalculator.php new file mode 100644 index 0000000..ca2f9bb --- /dev/null +++ b/app/Services/NutritionPlanCalculator.php @@ -0,0 +1,128 @@ + + * } + */ + public function calculate( + float $weightKg, + int $heightCm, + CarbonInterface $dateOfBirth, + UserSex $sex, + PhysicalActivityLevel $activityLevel, + WeightGoal $weightGoal, + PacePreference $pacePreference, + ): array { + $sexCoefficient = match ($sex) { + UserSex::MAN => 5, + UserSex::WOMAN => -161, + default => throw new InvalidArgumentException('A metabolic calculation reference is required.'), + }; + $age = (int) $dateOfBirth->diffInYears(now()); + $restingMetabolism = (int) round( + (10 * $weightKg) + (6.25 * $heightCm) - (5 * $age) + $sexCoefficient + ); + $activityFactor = $this->activityFactor($activityLevel); + $maintenanceCalories = (int) (round(($restingMetabolism * $activityFactor) / 10) * 10); + $adjustmentPercent = $this->calorieAdjustmentPercent($weightGoal, $pacePreference); + $unboundedCalories = (int) (round(($maintenanceCalories * (1 + $adjustmentPercent)) / 10) * 10); + $minimumCalories = max(1200, (int) (ceil(($restingMetabolism * 0.8) / 10) * 10)); + $calorieTarget = max($minimumCalories, $unboundedCalories); + $heightMeters = $heightCm / 100; + $proteinReferenceWeight = min($weightKg, 25 * ($heightMeters ** 2)); + $proteinFactor = $this->proteinFactor($activityLevel, $weightGoal); + $proteins = max(1, (int) round($proteinReferenceWeight * $proteinFactor)); + $fats = max(1, (int) round(($calorieTarget * 0.25) / 9)); + $carbs = max(0, (int) round(($calorieTarget - ($proteins * 4) - ($fats * 9)) / 4)); + $macroCalories = ($proteins * 4) + ($carbs * 4) + ($fats * 9); + + return [ + 'formula_version' => self::FORMULA_VERSION, + 'calories' => $macroCalories, + 'proteins' => $proteins, + 'carbs' => $carbs, + 'fats' => $fats, + 'resting_metabolism' => $restingMetabolism, + 'maintenance_calories' => $maintenanceCalories, + 'calorie_adjustment_percent' => $adjustmentPercent, + 'calculation_details' => [ + 'activityFactor' => $activityFactor, + 'ageAtCalculation' => $age, + 'heightCm' => $heightCm, + 'inputWeightKg' => round($weightKg, 2), + 'minimumCalories' => $minimumCalories, + 'pacePreference' => $pacePreference->value, + 'proteinFactor' => $proteinFactor, + 'proteinReferenceWeightKg' => round($proteinReferenceWeight, 2), + 'sexReference' => $sex->value, + 'weightGoal' => $weightGoal->value, + ], + ]; + } + + private function activityFactor(PhysicalActivityLevel $activityLevel): float + { + return match ($activityLevel) { + PhysicalActivityLevel::SEDENTARY => 1.2, + PhysicalActivityLevel::LIGHTLY_ACTIVE => 1.375, + PhysicalActivityLevel::MODERATELY_ACTIVE => 1.55, + PhysicalActivityLevel::VERY_ACTIVE => 1.725, + PhysicalActivityLevel::ATHLETE => 1.9, + }; + } + + private function calorieAdjustmentPercent(WeightGoal $weightGoal, PacePreference $pacePreference): float + { + if ($weightGoal === WeightGoal::MAINTAIN_WEIGHT) { + return 0; + } + + $magnitude = match ($pacePreference) { + PacePreference::SLOW => $weightGoal === WeightGoal::LOSE_WEIGHT ? 0.10 : 0.05, + PacePreference::NORMAL => $weightGoal === WeightGoal::LOSE_WEIGHT ? 0.15 : 0.10, + PacePreference::FAST => $weightGoal === WeightGoal::LOSE_WEIGHT ? 0.20 : 0.15, + }; + + return $weightGoal === WeightGoal::LOSE_WEIGHT ? -$magnitude : $magnitude; + } + + private function proteinFactor(PhysicalActivityLevel $activityLevel, WeightGoal $weightGoal): float + { + $factor = match ($activityLevel) { + PhysicalActivityLevel::SEDENTARY => 1.2, + PhysicalActivityLevel::LIGHTLY_ACTIVE => 1.4, + PhysicalActivityLevel::MODERATELY_ACTIVE => 1.6, + PhysicalActivityLevel::VERY_ACTIVE, PhysicalActivityLevel::ATHLETE => 1.8, + }; + + $factor += match ($weightGoal) { + WeightGoal::LOSE_WEIGHT => 0.2, + WeightGoal::GAIN_WEIGHT => 0.1, + WeightGoal::MAINTAIN_WEIGHT => 0, + }; + + return min($factor, 2.0); + } +} diff --git a/app/Services/WeeklyReviewService.php b/app/Services/WeeklyReviewService.php new file mode 100644 index 0000000..aebddfc --- /dev/null +++ b/app/Services/WeeklyReviewService.php @@ -0,0 +1,227 @@ +subWeek()->startOfWeek(CarbonInterface::MONDAY); + $periodStart = $periodStart->startOfDay(); + $periodEnd = $periodStart->endOfWeek(CarbonInterface::SUNDAY); + $existingReview = $user->weeklyReviews()->whereDate('period_start', $periodStart)->first(); + + if ($existingReview?->accepted_at !== null) { + return $existingReview; + } + + $currentPlan = $user->nutritionPlans()->where('is_active', true)->latest('effective_from')->firstOrFail(); + $nutritionPlan = $user->nutritionPlans() + ->where('effective_from', '<=', $periodEnd) + ->latest('effective_from') + ->first() + ?? $currentPlan; + $meals = MealPosts::query() + ->where('user_id', $user->getKey()) + ->whereBetween('eaten_at', [$periodStart, $periodEnd]) + ->get(['calories', 'proteins', 'carbs', 'fats', 'eaten_at']); + $loggedDaysCount = $meals->pluck('eaten_at') + ->map(fn (CarbonInterface $date): string => $date->toDateString()) + ->unique() + ->count(); + $averages = $this->nutritionAverages($meals, $loggedDaysCount); + $weights = $user->weightEntries() + ->whereBetween('measured_at', [$periodStart->subWeek(), $periodEnd]) + ->oldest('measured_at') + ->get(); + $trend = $this->weightTrend($weights); + $latestWeight = $weights->last()?->weight_kg ?? $user->latestWeightEntry()->value('weight_kg'); + $targetTrend = $latestWeight === null ? null : $this->targetWeightTrend( + (float) $latestWeight, + $user->weight_goal, + $user->pace_preference, + ); + [$status, $adjustment, $messageCode] = $this->recommendation( + loggedDaysCount: $loggedDaysCount, + weightCount: $weights->count(), + trend: $trend, + targetTrend: $targetTrend, + averageCalories: $averages['calories'], + planCalories: $nutritionPlan->calories, + weightGoal: $user->weight_goal, + ); + + if ($nutritionPlan->getKey() !== $currentPlan->getKey()) { + $status = WeeklyReviewStatus::INSUFFICIENT_DATA; + $adjustment = 0; + $messageCode = 'WEEKLY_REVIEW_PLAN_CHANGED'; + } + + return $user->weeklyReviews()->updateOrCreate( + ['period_start' => $periodStart->toDateString()], + [ + 'nutrition_plan_id' => $nutritionPlan->getKey(), + 'period_end' => $periodEnd->toDateString(), + 'average_calories' => $averages['calories'], + 'average_proteins' => $averages['proteins'], + 'average_carbs' => $averages['carbs'], + 'average_fats' => $averages['fats'], + 'logged_days_count' => $loggedDaysCount, + 'weight_entries_count' => $weights->count(), + 'weight_trend_kg' => $trend, + 'target_weight_trend_kg' => $targetTrend, + 'status' => $status, + 'recommended_calorie_adjustment' => $adjustment, + 'message_code' => $messageCode, + 'metrics' => [ + 'analysisStart' => $periodStart->subWeek()->toDateString(), + 'analysisEnd' => $periodEnd->toDateString(), + 'firstWeightKg' => $weights->first()?->weight_kg, + 'lastWeightKg' => $weights->last()?->weight_kg, + 'nutritionPlanCalories' => $nutritionPlan->calories, + ], + 'generated_at' => now(), + ], + ); + } + + /** + * @param Collection $meals + * @return array{calories: float, proteins: float, carbs: float, fats: float} + */ + private function nutritionAverages(Collection $meals, int $loggedDaysCount): array + { + $divisor = max(1, $loggedDaysCount); + + return [ + 'calories' => round((float) $meals->sum('calories') / $divisor, 2), + 'proteins' => round((float) $meals->sum('proteins') / $divisor, 2), + 'carbs' => round((float) $meals->sum('carbs') / $divisor, 2), + 'fats' => round((float) $meals->sum('fats') / $divisor, 2), + ]; + } + + private function weightTrend(Collection $weights): ?float + { + $first = $weights->first(); + $last = $weights->last(); + + if ($weights->count() < 4 || $first === null || $last === null) { + return null; + } + + $days = max(1, $first->measured_at->diffInDays($last->measured_at)); + + if ($days < 7) { + return null; + } + + return round((($last->weight_kg - $first->weight_kg) / $days) * 7, 2); + } + + private function targetWeightTrend( + float $weightKg, + WeightGoal $weightGoal, + PacePreference $pacePreference, + ): float { + $percentage = match ($weightGoal) { + WeightGoal::LOSE_WEIGHT => match ($pacePreference) { + PacePreference::SLOW => -0.0025, + PacePreference::NORMAL => -0.005, + PacePreference::FAST => -0.0075, + }, + WeightGoal::GAIN_WEIGHT => match ($pacePreference) { + PacePreference::SLOW => 0.00125, + PacePreference::NORMAL => 0.0025, + PacePreference::FAST => 0.00375, + }, + WeightGoal::MAINTAIN_WEIGHT => 0, + }; + + return round($weightKg * $percentage, 2); + } + + /** + * @return array{WeeklyReviewStatus, int, string} + */ + private function recommendation( + int $loggedDaysCount, + int $weightCount, + ?float $trend, + ?float $targetTrend, + float $averageCalories, + int $planCalories, + WeightGoal $weightGoal, + ): array { + if ($loggedDaysCount < 5 || $weightCount < 4 || $trend === null || $targetTrend === null) { + return [ + WeeklyReviewStatus::INSUFFICIENT_DATA, + 0, + 'WEEKLY_REVIEW_INSUFFICIENT_TRACKING', + ]; + } + + $calorieDifferenceRatio = abs($averageCalories - $planCalories) / max(1, $planCalories); + + if ($calorieDifferenceRatio > 0.15) { + return [ + WeeklyReviewStatus::INSUFFICIENT_DATA, + 0, + 'WEEKLY_REVIEW_INSUFFICIENT_ADHERENCE', + ]; + } + + $tolerance = $weightGoal === WeightGoal::MAINTAIN_WEIGHT + ? 0.2 + : max(0.1, abs($targetTrend) * 0.35); + $difference = $trend - $targetTrend; + + if (abs($difference) <= $tolerance) { + return [ + WeeklyReviewStatus::ON_TRACK, + 0, + 'WEEKLY_REVIEW_ON_TRACK', + ]; + } + + $adjustment = match ($weightGoal) { + WeightGoal::LOSE_WEIGHT => $difference > 0 ? -100 : 100, + WeightGoal::GAIN_WEIGHT => $difference < 0 ? 100 : -100, + WeightGoal::MAINTAIN_WEIGHT => $trend > 0 ? -100 : 100, + }; + + return [ + WeeklyReviewStatus::ADJUSTMENT_RECOMMENDED, + $adjustment, + $this->adjustmentMessage($weightGoal, $adjustment), + ]; + } + + private function adjustmentMessage(WeightGoal $weightGoal, int $adjustment): string + { + return match ($weightGoal) { + WeightGoal::LOSE_WEIGHT => $adjustment < 0 + ? 'WEEKLY_REVIEW_LOSS_SLOWER' + : 'WEEKLY_REVIEW_LOSS_FASTER', + WeightGoal::GAIN_WEIGHT => $adjustment > 0 + ? 'WEEKLY_REVIEW_GAIN_SLOWER' + : 'WEEKLY_REVIEW_GAIN_FASTER', + WeightGoal::MAINTAIN_WEIGHT => $adjustment > 0 + ? 'WEEKLY_REVIEW_MAINTENANCE_DOWN' + : 'WEEKLY_REVIEW_MAINTENANCE_UP', + }; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index d989a25..4eb689e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,11 +1,16 @@ withRouting( @@ -20,6 +25,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, 'not_suspended' => \App\Http\Middleware\EnsureAccountIsNotSuspended::class, + 'onboarded' => \App\Http\Middleware\EnsureNutritionOnboardingCompleted::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { @@ -29,7 +35,7 @@ return Application::configure(basePath: dirname(__DIR__)) } return response()->json([ - 'message' => __('api.auth.unauthenticated'), + 'code' => 'UNAUTHENTICATED', ], 401); }); @@ -39,7 +45,83 @@ return Application::configure(basePath: dirname(__DIR__)) } return response()->json([ - 'message' => __('api.auth.invalid_verification_link'), + 'code' => 'INVALID_VERIFICATION_LINK', ], 403); }); + + $exceptions->render(function (ValidationException $exception, Request $request) { + if (! $request->expectsJson()) { + return null; + } + + $failedRules = $exception->validator->failed(); + $errors = collect($exception->errors())->mapWithKeys( + function (array $messages, string $attribute) use ($failedRules): array { + $explicitCodes = collect($messages) + ->filter(fn (string $message): bool => preg_match('/^[A-Z][A-Z0-9_]+$/', $message) === 1) + ->values(); + + if ($explicitCodes->isNotEmpty()) { + return [$attribute => $explicitCodes->all()]; + } + + $codes = collect(array_keys($failedRules[$attribute] ?? [])) + ->map(fn (string $rule): string => 'VALIDATION_'.Str::upper(Str::snake(class_basename($rule)))) + ->values() + ->all(); + + return [$attribute => $codes !== [] ? $codes : ['VALIDATION_INVALID']]; + } + ); + + return response()->json([ + 'code' => 'VALIDATION_ERROR', + 'errors' => $errors, + ], $exception->status); + }); + + $exceptions->render(function (AuthorizationException $exception, Request $request) { + if (! $request->expectsJson()) { + return null; + } + + return response()->json(['code' => 'FORBIDDEN'], 403); + }); + + $exceptions->render(function (ModelNotFoundException $exception, Request $request) { + if (! $request->expectsJson()) { + return null; + } + + return response()->json(['code' => 'NOT_FOUND'], 404); + }); + + $exceptions->render(function (HttpExceptionInterface $exception, Request $request) { + if (! $request->expectsJson()) { + return null; + } + + $message = $exception->getMessage(); + $code = preg_match('/^[A-Z][A-Z0-9_]+$/', $message) === 1 + ? $message + : match ($exception->getStatusCode()) { + 401 => 'UNAUTHENTICATED', + 403 => 'FORBIDDEN', + 404 => 'NOT_FOUND', + 409 => 'CONFLICT', + 422 => 'VALIDATION_ERROR', + 429 => 'TOO_MANY_REQUESTS', + default => $exception->getStatusCode() >= 500 ? 'SERVER_ERROR' : 'REQUEST_FAILED', + }; + + return response()->json(['code' => $code], $exception->getStatusCode()); + }); + + $exceptions->render(function (Throwable $exception, Request $request) { + if (! $request->expectsJson()) { + return null; + } + + return response()->json(['code' => 'SERVER_ERROR'], 500); + }); })->create(); diff --git a/database/factories/NutritionPlanFactory.php b/database/factories/NutritionPlanFactory.php new file mode 100644 index 0000000..df3c56e --- /dev/null +++ b/database/factories/NutritionPlanFactory.php @@ -0,0 +1,41 @@ + + */ +class NutritionPlanFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'source' => NutritionPlanSource::ONBOARDING, + 'formula_version' => 'mifflin_st_jeor_v1', + 'calories' => 2200, + 'proteins' => 112, + 'carbs' => 286, + 'fats' => 61, + 'resting_metabolism' => 1650, + 'maintenance_calories' => 2200, + 'calorie_adjustment_percent' => 0, + 'calculation_details' => [ + 'activityFactor' => 1.55, + 'inputWeightKg' => 70, + ], + 'is_active' => true, + 'effective_from' => now(), + 'effective_until' => null, + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 0f4e460..fc353f5 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,7 +2,13 @@ namespace Database\Factories; +use App\Enums\NutritionPlanSource; +use App\Enums\PacePreference; +use App\Enums\PhysicalActivityLevel; use App\Enums\UserRole; +use App\Enums\UserSex; +use App\Enums\WeightGoal; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -32,14 +38,50 @@ class UserFactory extends Factory 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'avatar_url' => null, - 'daily_calorie_goal' => 2000, - 'daily_protein_goal' => 120, - 'daily_carbs_goal' => 250, - 'daily_fats_goal' => 70, + 'height' => 175, + 'target_weight' => 70, + 'date_of_birth' => '1990-01-01', + 'sex' => UserSex::MAN, + 'physical_activity_level' => PhysicalActivityLevel::MODERATELY_ACTIVE, + 'weight_goal' => WeightGoal::MAINTAIN_WEIGHT, + 'pace_preference' => PacePreference::NORMAL, + 'onboarding_completed_at' => now(), + 'nutrition_estimate_accepted_at' => now(), 'remember_token' => Str::random(10), ]; } + public function configure(): static + { + return $this->afterCreating(function (User $user): void { + if (! $user->hasCompletedNutritionOnboarding()) { + return; + } + + $user->weightEntries()->create([ + 'weight_kg' => 70, + 'measured_at' => now(), + ]); + + $user->nutritionPlans()->create([ + 'source' => NutritionPlanSource::ONBOARDING, + 'formula_version' => 'mifflin_st_jeor_v1', + 'calories' => 2200, + 'proteins' => 112, + 'carbs' => 286, + 'fats' => 61, + 'resting_metabolism' => 1650, + 'maintenance_calories' => 2200, + 'calorie_adjustment_percent' => 0, + 'calculation_details' => [ + 'activityFactor' => 1.55, + 'inputWeightKg' => 70, + ], + 'effective_from' => now(), + ]); + }); + } + /** * Indicate that the model's email address should be unverified. */ @@ -49,4 +91,34 @@ class UserFactory extends Factory 'email_verified_at' => null, ]); } + + public function onboarded(): static + { + return $this->state(fn (array $attributes): array => [ + 'height' => 175, + 'target_weight' => 70, + 'date_of_birth' => '1990-01-01', + 'sex' => UserSex::MAN, + 'physical_activity_level' => PhysicalActivityLevel::MODERATELY_ACTIVE, + 'weight_goal' => WeightGoal::MAINTAIN_WEIGHT, + 'pace_preference' => PacePreference::NORMAL, + 'onboarding_completed_at' => now(), + 'nutrition_estimate_accepted_at' => now(), + ]); + } + + public function withoutNutritionOnboarding(): static + { + return $this->state(fn (array $attributes): array => [ + 'height' => null, + 'target_weight' => null, + 'date_of_birth' => null, + 'sex' => null, + 'physical_activity_level' => null, + 'weight_goal' => null, + 'pace_preference' => null, + 'onboarding_completed_at' => null, + 'nutrition_estimate_accepted_at' => null, + ]); + } } diff --git a/database/factories/WeeklyReviewFactory.php b/database/factories/WeeklyReviewFactory.php new file mode 100644 index 0000000..4623928 --- /dev/null +++ b/database/factories/WeeklyReviewFactory.php @@ -0,0 +1,43 @@ + + */ +class WeeklyReviewFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'nutrition_plan_id' => null, + 'period_start' => now()->startOfWeek(), + 'period_end' => now()->endOfWeek(), + 'average_calories' => 2100, + 'average_proteins' => 110, + 'average_carbs' => 260, + 'average_fats' => 70, + 'logged_days_count' => 7, + 'weight_entries_count' => 4, + 'weight_trend_kg' => -0.3, + 'target_weight_trend_kg' => -0.35, + 'status' => WeeklyReviewStatus::ON_TRACK, + 'recommended_calorie_adjustment' => 0, + 'message_code' => 'WEEKLY_REVIEW_ON_TRACK', + 'metrics' => [], + 'generated_at' => now(), + 'accepted_at' => null, + 'accepted_nutrition_plan_id' => null, + ]; + } +} diff --git a/database/factories/WeightEntryFactory.php b/database/factories/WeightEntryFactory.php new file mode 100644 index 0000000..f831e34 --- /dev/null +++ b/database/factories/WeightEntryFactory.php @@ -0,0 +1,27 @@ + + */ +class WeightEntryFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'weight_kg' => fake()->randomFloat(2, 45, 120), + 'source' => 'manual', + 'measured_at' => now(), + ]; + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index b6ef4df..0109556 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -19,20 +19,17 @@ return new class extends Migration $table->string('password'); $table->string('role')->default('user'); $table->text('bio')->nullable(); - $table->integer('height')->nullable(); + $table->unsignedSmallInteger('height')->nullable(); + $table->decimal('target_weight', 5, 2)->nullable(); $table->date('date_of_birth')->nullable(); - $table->string('sex')->default('unknown'); - $table->string('physical_activity_level')->default('sedentary'); - $table->string('weight_goal')->default('lose_weight'); - $table->string('pace_preference')->default('slow'); + $table->string('sex')->nullable(); + $table->string('physical_activity_level')->nullable(); + $table->string('weight_goal')->nullable(); + $table->string('pace_preference')->nullable(); $table->string('locale', 8)->default('en')->after('email'); $table->string('avatar_url', 2048)->nullable(); - - // Goals - $table->unsignedInteger('daily_calorie_goal')->default(2000)->after('avatar_url'); - $table->decimal('daily_protein_goal', 8, 2)->default(120)->after('daily_calorie_goal'); - $table->decimal('daily_carbs_goal', 8, 2)->default(250)->after('daily_protein_goal'); - $table->decimal('daily_fats_goal', 8, 2)->default(70)->after('daily_carbs_goal'); + $table->timestampTz('onboarding_completed_at')->nullable()->index(); + $table->timestampTz('nutrition_estimate_accepted_at')->nullable(); // Moderation $table->timestamp('suspended_at')->nullable()->after('deleted_at')->index(); $table->ulid('suspended_by')->nullable()->after('suspended_at')->index(); diff --git a/database/migrations/2026_08_14_075205_create_nutrition_plans_table.php b/database/migrations/2026_08_14_075205_create_nutrition_plans_table.php new file mode 100644 index 0000000..51069be --- /dev/null +++ b/database/migrations/2026_08_14_075205_create_nutrition_plans_table.php @@ -0,0 +1,44 @@ +ulid('id')->primary(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('source', 32); + $table->string('formula_version', 64); + $table->unsignedInteger('calories'); + $table->decimal('proteins', 8, 2); + $table->decimal('carbs', 8, 2); + $table->decimal('fats', 8, 2); + $table->unsignedInteger('resting_metabolism'); + $table->unsignedInteger('maintenance_calories'); + $table->decimal('calorie_adjustment_percent', 6, 4)->default(0); + $table->json('calculation_details'); + $table->boolean('is_active')->default(true); + $table->timestampTz('effective_from'); + $table->timestampTz('effective_until')->nullable(); + $table->timestampsTz(); + + $table->index(['user_id', 'is_active']); + $table->index(['user_id', 'effective_from']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('nutrition_plans'); + } +}; diff --git a/database/migrations/2026_08_14_075205_create_weight_entries_table.php b/database/migrations/2026_08_14_075205_create_weight_entries_table.php new file mode 100644 index 0000000..b331d94 --- /dev/null +++ b/database/migrations/2026_08_14_075205_create_weight_entries_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->decimal('weight_kg', 5, 2); + $table->string('source', 32)->default('manual'); + $table->timestampTz('measured_at'); + $table->timestampsTz(); + + $table->index(['user_id', 'measured_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('weight_entries'); + } +}; diff --git a/database/migrations/2026_08_14_075206_create_weekly_reviews_table.php b/database/migrations/2026_08_14_075206_create_weekly_reviews_table.php new file mode 100644 index 0000000..141cb72 --- /dev/null +++ b/database/migrations/2026_08_14_075206_create_weekly_reviews_table.php @@ -0,0 +1,49 @@ +ulid('id')->primary(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('nutrition_plan_id')->nullable()->constrained('nutrition_plans')->nullOnDelete(); + $table->date('period_start'); + $table->date('period_end'); + $table->decimal('average_calories', 8, 2)->default(0); + $table->decimal('average_proteins', 8, 2)->default(0); + $table->decimal('average_carbs', 8, 2)->default(0); + $table->decimal('average_fats', 8, 2)->default(0); + $table->unsignedTinyInteger('logged_days_count')->default(0); + $table->unsignedTinyInteger('weight_entries_count')->default(0); + $table->decimal('weight_trend_kg', 5, 2)->nullable(); + $table->decimal('target_weight_trend_kg', 5, 2)->nullable(); + $table->string('status', 32); + $table->smallInteger('recommended_calorie_adjustment')->default(0); + $table->string('message_code', 80); + $table->json('metrics'); + $table->timestampTz('generated_at'); + $table->timestampTz('accepted_at')->nullable(); + $table->foreignUlid('accepted_nutrition_plan_id')->nullable()->constrained('nutrition_plans')->nullOnDelete(); + $table->timestampsTz(); + + $table->unique(['user_id', 'period_start']); + $table->index(['user_id', 'period_end']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('weekly_reviews'); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 2688c55..bf4d5a7 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -16,7 +16,7 @@ return [ 'sections' => [ 'account' => 'Account', 'profile' => 'Profile', - 'nutrition_goals' => 'Nutrition goals', + 'nutrition_plan' => 'Active nutrition plan', 'activity' => 'Activity', 'metadata' => 'Metadata', ], @@ -36,17 +36,20 @@ return [ 'password' => 'Password', 'bio' => 'Bio', 'height' => 'Height', + 'current_weight' => 'Current weight', + 'target_weight' => 'Target weight', 'date_of_birth' => 'Date of birth', 'sex' => 'Sex', - 'daily_calorie_goal' => 'Calories', - 'daily_protein_goal' => 'Protein', - 'daily_carbs_goal' => 'Carbs', - 'daily_fats_goal' => 'Fats', + 'calorie_goal' => 'Calories', + 'protein_goal' => 'Protein', + 'carbs_goal' => 'Carbs', + 'fats_goal' => 'Fats', 'physical_activity_level' => 'Activity level', 'physical_activity_level_short' => 'Activity', 'weight_goal' => 'Weight goal', 'weight_goal_short' => 'Weight goal', 'pace_preference' => 'Pace', + 'onboarding_completed_at' => 'Nutrition profile completed at', 'created_at' => 'Created at', 'updated_at' => 'Updated at', ], diff --git a/lang/en/api.php b/lang/en/api.php index 58ef55d..d229bc1 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -125,6 +125,32 @@ return [ 'blocked' => 'User blocked.', 'cannot_block_self' => 'You cannot block yourself.', ], + 'nutrition' => [ + 'onboarding_required' => 'Complete your nutrition profile before continuing.', + 'weekly_adjustment_unavailable' => 'No weekly adjustment is available.', + 'weekly_adjustment_already_accepted' => 'This adjustment has already been applied.', + 'weekly_adjustment_stale' => 'Your plan changed after this review. Generate a new review before adapting it.', + 'validation' => [ + 'adults_only' => 'Automatic calculation is available to adults only.', + 'birth_date_range' => 'This birth date cannot be used for the calculation.', + 'sex_reference' => 'Choose the female or male calculation reference.', + 'estimate_acceptance' => 'You must confirm that you understand this is an estimate.', + 'target_lower' => 'The target weight must be lower than the current weight.', + 'target_higher' => 'The target weight must be higher than the current weight.', + ], + 'weekly_review' => [ + 'insufficient_tracking' => 'Log at least 5 days of meals and 4 weigh-ins across two weeks to receive a recommendation.', + 'insufficient_adherence' => 'Food tracking is still too far from the plan to make a reliable adjustment.', + 'on_track' => 'Your weight trend matches your goal. The plan remains unchanged.', + 'plan_changed' => 'Your plan changed after this period. Keep tracking with the new plan before adapting it.', + 'loss_slower' => 'Weight loss is slower than expected. A small calorie decrease is proposed.', + 'loss_faster' => 'Weight loss is faster than expected. A small calorie increase is proposed.', + 'gain_slower' => 'Weight gain is slower than expected. A small calorie increase is proposed.', + 'gain_faster' => 'Weight gain is faster than expected. A small calorie decrease is proposed.', + 'maintenance_down' => 'Weight is trending down. A small calorie increase is proposed.', + 'maintenance_up' => 'Weight is trending up. A small calorie decrease is proposed.', + ], + ], 'validation' => [ 'image_required' => 'Add an image to analyze.', 'image_file' => 'The file must be an image.', @@ -134,9 +160,6 @@ return [ 'visibility_valid' => 'Choose a valid visibility.', 'month_valid' => 'Choose a valid month.', 'calories_integer' => 'Calories must be an integer.', - 'nutrition_goals_required' => 'Fill in all nutrition goals.', - 'nutrition_goals_positive' => 'Nutrition goals must be positive.', - 'nutrition_goals_max' => 'This nutrition goal value is too high.', 'report_reason_required' => 'Choose a report reason.', 'report_reason_valid' => 'Choose a valid report reason.', 'report_details_max' => 'The report details may not be greater than 2000 characters.', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index c17a1a7..b63f8ca 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -16,7 +16,7 @@ return [ 'sections' => [ 'account' => 'Compte', 'profile' => 'Profil', - 'nutrition_goals' => 'Objectifs nutritionnels', + 'nutrition_plan' => 'Plan nutritionnel actif', 'activity' => 'Activité', 'metadata' => 'Métadonnées', ], @@ -36,17 +36,20 @@ return [ 'password' => 'Mot de passe', 'bio' => 'Bio', 'height' => 'Taille', + 'current_weight' => 'Poids actuel', + 'target_weight' => 'Poids cible', 'date_of_birth' => 'Date de naissance', 'sex' => 'Sexe', - 'daily_calorie_goal' => 'Calories', - 'daily_protein_goal' => 'Protéines', - 'daily_carbs_goal' => 'Glucides', - 'daily_fats_goal' => 'Lipides', + 'calorie_goal' => 'Calories', + 'protein_goal' => 'Protéines', + 'carbs_goal' => 'Glucides', + 'fats_goal' => 'Lipides', 'physical_activity_level' => 'Niveau d’activité', 'physical_activity_level_short' => 'Activité', 'weight_goal' => 'Objectif de poids', 'weight_goal_short' => 'Objectif poids', 'pace_preference' => 'Rythme', + 'onboarding_completed_at' => 'Profil nutritionnel complété le', 'created_at' => 'Créé le', 'updated_at' => 'Mis à jour le', ], diff --git a/lang/fr/api.php b/lang/fr/api.php index 780b115..07c721e 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -125,6 +125,32 @@ return [ 'blocked' => 'Utilisateur bloqué.', 'cannot_block_self' => 'Vous ne pouvez pas vous bloquer vous-même.', ], + 'nutrition' => [ + 'onboarding_required' => 'Termine ton profil nutritionnel avant de continuer.', + 'weekly_adjustment_unavailable' => 'Aucun ajustement hebdomadaire n’est disponible.', + 'weekly_adjustment_already_accepted' => 'Cet ajustement a déjà été appliqué.', + 'weekly_adjustment_stale' => 'Ton plan a changé depuis ce bilan. Génère un nouveau bilan avant de l’adapter.', + 'validation' => [ + 'adults_only' => 'Le calcul automatique est réservé aux personnes majeures.', + 'birth_date_range' => 'La date de naissance saisie ne peut pas être utilisée pour ce calcul.', + 'sex_reference' => 'Choisis la référence de calcul femme ou homme.', + 'estimate_acceptance' => 'Tu dois confirmer avoir compris qu’il s’agit d’une estimation.', + 'target_lower' => 'Le poids cible doit être inférieur au poids actuel.', + 'target_higher' => 'Le poids cible doit être supérieur au poids actuel.', + ], + 'weekly_review' => [ + 'insufficient_tracking' => 'Ajoute au moins 5 journées de repas et 4 pesées réparties sur deux semaines pour obtenir une recommandation.', + 'insufficient_adherence' => 'Le suivi alimentaire est encore trop éloigné du plan pour proposer un ajustement fiable.', + 'on_track' => 'Ta tendance de poids est cohérente avec ton objectif. Le plan reste inchangé.', + 'plan_changed' => 'Ton plan a changé depuis cette période. Continue le suivi avec le nouveau plan avant de l’adapter.', + 'loss_slower' => 'La perte est plus lente que prévu. Une baisse légère du budget est proposée.', + 'loss_faster' => 'La perte est plus rapide que prévu. Une hausse légère du budget est proposée.', + 'gain_slower' => 'La prise est plus lente que prévu. Une hausse légère du budget est proposée.', + 'gain_faster' => 'La prise est plus rapide que prévu. Une baisse légère du budget est proposée.', + 'maintenance_down' => 'Le poids tend à baisser. Une hausse légère du budget est proposée.', + 'maintenance_up' => 'Le poids tend à monter. Une baisse légère du budget est proposée.', + ], + ], 'validation' => [ 'image_required' => 'Ajoute une image à analyser.', 'image_file' => 'Le fichier doit être une image.', @@ -134,9 +160,6 @@ return [ 'visibility_valid' => 'Choisis une visibilité valide.', 'month_valid' => 'Choisis un mois valide.', 'calories_integer' => 'Les calories doivent être un nombre entier.', - 'nutrition_goals_required' => 'Renseigne tous les objectifs nutritionnels.', - 'nutrition_goals_positive' => 'Les objectifs nutritionnels doivent être positifs.', - 'nutrition_goals_max' => 'La valeur de cet objectif nutritionnel est trop élevée.', 'report_reason_required' => 'Choisis une raison de signalement.', 'report_reason_valid' => 'Choisis une raison de signalement valide.', 'report_details_max' => 'Le détail du signalement ne doit pas dépasser 2000 caractères.', diff --git a/routes/api.php b/routes/api.php index 6260b1e..b6d3bd2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,9 @@ use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationPreferenceController; +use App\Http\Controllers\NutritionOnboardingController; +use App\Http\Controllers\NutritionPlanController; +use App\Http\Controllers\NutritionProfileController; use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PublicUserProfileController; use App\Http\Controllers\ReportController; @@ -16,6 +19,8 @@ use App\Http\Controllers\RevenueCatController; use App\Http\Controllers\SearchController; use App\Http\Controllers\StravaController; use App\Http\Controllers\UserBlockController; +use App\Http\Controllers\WeeklyReviewController; +use App\Http\Controllers\WeightEntryController; use App\Http\Controllers\WorkoutSessionController; use Illuminate\Support\Facades\Route; @@ -59,6 +64,32 @@ Route::prefix('legal-documents')->group(function (): void { }); Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { + Route::post('nutrition-onboarding', NutritionOnboardingController::class) + ->middleware('throttle:account-action') + ->name('nutrition-onboarding.complete'); +}); + +Route::middleware(['auth:sanctum', 'verified', 'not_suspended', 'onboarded'])->group(function (): void { + Route::patch('nutrition-profile', NutritionProfileController::class) + ->middleware('throttle:account-action') + ->name('nutrition-profile.update'); + Route::get('nutrition-plans', [NutritionPlanController::class, 'index'])->name('nutrition-plans.index'); + Route::get('nutrition-plans/current', [NutritionPlanController::class, 'current'])->name('nutrition-plans.current'); + Route::get('weight-entries', [WeightEntryController::class, 'index'])->name('weight-entries.index'); + Route::post('weight-entries', [WeightEntryController::class, 'store']) + ->middleware('throttle:content-write') + ->name('weight-entries.store'); + Route::delete('weight-entries/{weightEntry}', [WeightEntryController::class, 'destroy']) + ->middleware('throttle:content-write') + ->name('weight-entries.destroy'); + Route::get('weekly-reviews', [WeeklyReviewController::class, 'index'])->name('weekly-reviews.index'); + Route::get('weekly-reviews/current', [WeeklyReviewController::class, 'current'])->name('weekly-reviews.current'); + Route::post('weekly-reviews/{weeklyReview}/accept', [WeeklyReviewController::class, 'accept']) + ->middleware('throttle:account-action') + ->name('weekly-reviews.accept'); +}); + +Route::middleware(['auth:sanctum', 'verified', 'not_suspended', 'onboarded'])->group(function (): void { Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token'); Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->middleware('throttle:device-token'); Route::get('notifications', [NotificationController::class, 'index'])->name('notifications.index'); @@ -74,13 +105,13 @@ Route::post('webhooks/revenuecat', [RevenueCatController::class, 'webhook']) ->name('revenuecat.webhook'); // Billing -Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->prefix('billing')->group(function (): void { +Route::middleware(['auth:sanctum', 'verified', 'not_suspended', 'onboarded'])->prefix('billing')->group(function (): void { Route::post('sync', [RevenueCatController::class, 'sync']) ->middleware('throttle:account-action') ->name('billing.sync'); }); -Route::middleware(['auth:sanctum', 'verified', 'not_suspended']) +Route::middleware(['auth:sanctum', 'verified', 'not_suspended', 'onboarded']) ->prefix('certification') ->group(function (): void { Route::get('/', [CertificationController::class, 'show'])->name('certification.show'); @@ -90,7 +121,7 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended']) }); // Workouts -Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { +Route::middleware(['auth:sanctum', 'verified', 'not_suspended', 'onboarded'])->group(function (): void { Route::get('workouts/calendar', [WorkoutSessionController::class, 'calendar'])->name('workouts.calendar'); Route::get('workouts', [WorkoutSessionController::class, 'index'])->name('workouts.index'); Route::post('workouts', [WorkoutSessionController::class, 'store'])->middleware('throttle:content-write')->name('workouts.store'); @@ -102,7 +133,7 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function }); // Meals -Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { +Route::middleware(['auth:sanctum', 'verified', 'not_suspended', 'onboarded'])->group(function (): void { Route::get('search', SearchController::class)->name('search'); Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show'); Route::get('me/blocked-users', [UserBlockController::class, 'index'])->name('users.blocks.index'); diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php index cd38716..75b3e00 100644 --- a/tests/Feature/EmailVerificationTest.php +++ b/tests/Feature/EmailVerificationTest.php @@ -1,9 +1,5 @@ 'leon@example.com', 'password' => 'Motsdfdepasse123*', 'locale' => 'fr-FR', - 'physicalActivityLevel' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value, - 'weightGoal' => WeightGoal::MAINTAIN_WEIGHT->value, - 'pacePreference' => PacePreference::NORMAL->value, - 'sex' => UserSex::WOMAN->value, - 'dateOfBirth' => '2003-04-24', 'termsAccepted' => true, ]) ->assertCreated() @@ -35,11 +26,8 @@ it('sends a verification email when a user registers', function () { ->assertJsonPath('user.emailVerified', false) ->assertJsonPath('user.analysisAccessLevel', 'free') ->assertJsonPath('user.canAnalyzeMeals', false) - ->assertJsonPath('user.physicalActivityLevel', PhysicalActivityLevel::LIGHTLY_ACTIVE->value) - ->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value) - ->assertJsonPath('user.pacePreference', PacePreference::NORMAL->value) - ->assertJsonPath('user.sex', UserSex::WOMAN->value) - ->assertJsonPath('user.dateOfBirth', '2003-04-24') + ->assertJsonPath('user.onboardingStatus', 'required') + ->assertJsonPath('user.nutritionGoals', null) ->assertCookieMissing('token'); $user = User::where('email', 'leon@example.com')->firstOrFail(); @@ -51,13 +39,13 @@ it('sends a verification email when a user registers', function () { $this->assertDatabaseHas('users', [ 'id' => $user->id, 'locale' => 'fr', - 'physical_activity_level' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value, - 'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value, - 'pace_preference' => PacePreference::NORMAL->value, - 'sex' => UserSex::WOMAN->value, + 'physical_activity_level' => null, + 'weight_goal' => null, + 'pace_preference' => null, + 'sex' => null, ]); - expect($user->date_of_birth?->toDateString())->toBe('2003-04-24'); + expect($user->date_of_birth)->toBeNull(); Notification::assertSentTo($user, VerifyEmail::class); }); @@ -75,7 +63,7 @@ it('returns a temporary error when the verification email cannot be sent during 'termsAccepted' => true, ]) ->assertServiceUnavailable() - ->assertJsonPath('message', __('api.auth.verification_email_failed')); + ->assertJsonPath('code', 'VERIFICATION_EMAIL_FAILED'); $this->assertDatabaseMissing('users', [ 'email' => 'leon@example.com', @@ -91,16 +79,12 @@ it('registers mobile users with a bearer token', function () { 'password' => 'Motsdfdepasse123*', 'deviceName' => 'Bowli Android', 'locale' => 'fr-FR', - 'physicalActivityLevel' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value, - 'weightGoal' => WeightGoal::MAINTAIN_WEIGHT->value, - 'pacePreference' => PacePreference::NORMAL->value, - 'sex' => UserSex::MAN->value, - 'dateOfBirth' => '2003-04-24', 'termsAccepted' => true, ]) ->assertCreated() ->assertJsonPath('user.email', 'mobile.leon@example.com') ->assertJsonPath('user.emailVerified', false) + ->assertJsonPath('user.onboardingStatus', 'required') ->assertJsonStructure(['token']) ->assertCookieMissing('token'); @@ -183,7 +167,7 @@ it('generates verification links with a relative signature', function () { ->withServerVariables(['HTTP_HOST' => 'different-host.test']) ->getJson($requestUrl) ->assertOk() - ->assertJsonPath('message', __('api.auth.verified')); + ->assertJsonPath('code', 'EMAIL_VERIFIED'); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); }); @@ -197,7 +181,7 @@ it('verifies a user from a signed email link', function () { $this->getJson($url) ->assertOk() - ->assertJsonPath('message', __('api.auth.verified')) + ->assertJsonPath('code', 'EMAIL_VERIFIED') ->assertJsonPath('user.emailVerified', true); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); @@ -225,7 +209,7 @@ it('blocks login for unverified users', function () { 'password' => 'password', ]) ->assertForbidden() - ->assertJsonPath('message', __('api.auth.email_not_verified')) + ->assertJsonPath('code', 'EMAIL_NOT_VERIFIED') ->assertCookieMissing('token'); $this->assertDatabaseMissing('personal_access_tokens', [ @@ -288,7 +272,7 @@ it('logs out mobile users by deleting the current bearer token', function () { ->withToken($token) ->postJson('/api/auth/logout') ->assertOk() - ->assertJsonPath('message', __('api.auth.logout')); + ->assertJsonPath('code', 'LOGGED_OUT'); $this->assertDatabaseMissing('personal_access_tokens', [ 'name' => 'Bowli iOS', @@ -305,7 +289,7 @@ it('resends a verification email for an unverified user without authentication', 'email' => $user->email, ]) ->assertOk() - ->assertJsonPath('message', __('api.auth.verification_sent_if_unverified')); + ->assertJsonPath('code', 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED'); Notification::assertSentTo($user, VerifyEmail::class); }); @@ -319,13 +303,13 @@ it('does not reveal whether an email can receive a verification email', function 'email' => $verifiedUser->email, ]) ->assertOk() - ->assertJsonPath('message', __('api.auth.verification_sent_if_unverified')); + ->assertJsonPath('code', 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED'); $this->postJson('/api/auth/email/verification-notification', [ 'email' => 'missing@example.com', ]) ->assertOk() - ->assertJsonPath('message', __('api.auth.verification_sent_if_unverified')); + ->assertJsonPath('code', 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED'); Notification::assertNothingSent(); }); diff --git a/tests/Feature/NutritionOnboardingTest.php b/tests/Feature/NutritionOnboardingTest.php new file mode 100644 index 0000000..1adb08f --- /dev/null +++ b/tests/Feature/NutritionOnboardingTest.php @@ -0,0 +1,93 @@ + '1992-04-18', + 'sex' => UserSex::WOMAN->value, + 'heightCm' => 168, + 'weightKg' => 72.4, + 'targetWeightKg' => 66, + 'physicalActivityLevel' => PhysicalActivityLevel::MODERATELY_ACTIVE->value, + 'weightGoal' => WeightGoal::LOSE_WEIGHT->value, + 'pacePreference' => PacePreference::NORMAL->value, + 'nutritionEstimateAccepted' => true, + ...$overrides, + ]; +} + +it('returns an explicit onboarding state and blocks application endpoints', function () { + $user = User::factory()->withoutNutritionOnboarding()->create(); + Sanctum::actingAs($user); + + $this->getJson('/api/auth/me') + ->assertOk() + ->assertJsonPath('data.onboardingStatus', 'required') + ->assertJsonPath('data.nutritionGoals', null) + ->assertJsonPath('data.currentWeight', null); + + $this->getJson('/api/meal-posts/stats?period=day&date=2026-08-14') + ->assertStatus(409) + ->assertJsonPath('code', 'ONBOARDING_REQUIRED') + ->assertJsonMissingPath('message'); +}); + +it('completes onboarding atomically and returns the calculated plan', function () { + $user = User::factory()->withoutNutritionOnboarding()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/nutrition-onboarding', onboardingPayload()); + + $response + ->assertOk() + ->assertJsonPath('data.onboardingStatus', 'completed') + ->assertJsonPath('data.height', 168) + ->assertJsonPath('data.currentWeight', 72.4) + ->assertJsonPath('data.targetWeight', 66) + ->assertJsonPath('data.nutritionPlan.formulaVersion', 'mifflin_st_jeor_v1') + ->assertJsonPath('data.nutritionPlan.source', 'onboarding'); + + expect($response->json('data.nutritionGoals.calories'))->toBeGreaterThan(1200); + + $this->assertDatabaseCount('weight_entries', 1); + $this->assertDatabaseCount('nutrition_plans', 1); + expect($user->fresh()->onboarding_completed_at)->not->toBeNull(); +}); + +it('is idempotent when the completion response is retried', function () { + $user = User::factory()->withoutNutritionOnboarding()->create(); + Sanctum::actingAs($user); + + $this->postJson('/api/nutrition-onboarding', onboardingPayload())->assertOk(); + $this->postJson('/api/nutrition-onboarding', onboardingPayload())->assertOk(); + + $this->assertDatabaseCount('weight_entries', 1); + $this->assertDatabaseCount('nutrition_plans', 1); +}); + +it('validates adult age, formula reference and target direction', function () { + Sanctum::actingAs(User::factory()->withoutNutritionOnboarding()->create()); + + $this->postJson('/api/nutrition-onboarding', onboardingPayload([ + 'dateOfBirth' => now()->subYears(17)->toDateString(), + 'sex' => UserSex::OTHER->value, + 'targetWeightKg' => 80, + ])) + ->assertUnprocessable() + ->assertJsonValidationErrors(['dateOfBirth', 'sex', 'targetWeightKg']) + ->assertJsonPath('code', 'VALIDATION_ERROR') + ->assertJsonPath('errors.dateOfBirth.0', 'NUTRITION_ADULTS_ONLY') + ->assertJsonPath('errors.sex.0', 'NUTRITION_METABOLIC_SEX_REQUIRED') + ->assertJsonPath('errors.targetWeightKg.0', 'NUTRITION_TARGET_WEIGHT_MUST_BE_LOWER') + ->assertJsonMissingPath('message'); +}); diff --git a/tests/Feature/UserNutritionGoalsTest.php b/tests/Feature/UserNutritionGoalsTest.php index 7450dc4..85f2e69 100644 --- a/tests/Feature/UserNutritionGoalsTest.php +++ b/tests/Feature/UserNutritionGoalsTest.php @@ -11,11 +11,12 @@ use Laravel\Sanctum\Sanctum; uses(RefreshDatabase::class); it('returns nutrition goals with the authenticated user', function () { - $user = User::factory()->create([ - 'daily_calorie_goal' => 2400, - 'daily_protein_goal' => 140, - 'daily_carbs_goal' => 280, - 'daily_fats_goal' => 80, + $user = User::factory()->create(); + $user->nutritionPlans()->where('is_active', true)->update([ + 'calories' => 2400, + 'proteins' => 140, + 'carbs' => 280, + 'fats' => 80, ]); Sanctum::actingAs($user); @@ -29,52 +30,48 @@ it('returns nutrition goals with the authenticated user', function () { ->assertJsonPath('data.nutritionGoals.fats', 80); }); -it('updates nutrition goals for the authenticated user', function () { +it('recalculates nutrition goals from the complete profile', function () { $user = User::factory()->create(); Sanctum::actingAs($user); - $response = $this->patchJson('/api/auth/me', [ - 'nutritionGoals' => [ - 'calories' => 2200, - 'proteins' => 135.5, - 'carbs' => 260, - 'fats' => 75.25, - ], + $response = $this->patchJson('/api/nutrition-profile', [ + 'dateOfBirth' => '1991-06-12', + 'sex' => UserSex::MAN->value, + 'heightCm' => 182, + 'weightKg' => 84, + 'targetWeightKg' => 78, + 'physicalActivityLevel' => PhysicalActivityLevel::VERY_ACTIVE->value, + 'weightGoal' => WeightGoal::LOSE_WEIGHT->value, + 'pacePreference' => PacePreference::NORMAL->value, ]); $response ->assertOk() - ->assertJsonPath('data.nutritionGoals.calories', 2200) - ->assertJsonPath('data.nutritionGoals.proteins', 135.5) - ->assertJsonPath('data.nutritionGoals.carbs', 260) - ->assertJsonPath('data.nutritionGoals.fats', 75.25); + ->assertJsonPath('data.nutritionPlan.source', 'profile_recalculation') + ->assertJsonPath('data.currentWeight', 84) + ->assertJsonPath('data.targetWeight', 78); - $this->assertDatabaseHas('users', [ - 'id' => $user->id, - 'daily_calorie_goal' => 2200, - 'daily_protein_goal' => 135.5, - 'daily_carbs_goal' => 260, - 'daily_fats_goal' => 75.25, - ]); + expect($response->json('data.nutritionGoals.calories'))->toBeGreaterThan(1200); + $this->assertDatabaseCount('nutrition_plans', 2); + $this->assertDatabaseCount('weight_entries', 2); + $this->assertDatabaseHas('nutrition_plans', ['is_active' => false]); }); -it('validates nutrition goals', function () { - Sanctum::actingAs(User::factory()->create()); +it('does not allow direct nutrition goal overrides through the account endpoint', function () { + $user = User::factory()->create(); + Sanctum::actingAs($user); $this->patchJson('/api/auth/me', [ 'nutritionGoals' => [ - 'calories' => -1, - 'proteins' => 'abc', + 'calories' => 9000, + 'proteins' => 1, + 'carbs' => 1, + 'fats' => 1, ], ]) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - 'nutritionGoals.calories', - 'nutritionGoals.proteins', - 'nutritionGoals.carbs', - 'nutritionGoals.fats', - ]); + ->assertOk() + ->assertJsonPath('data.nutritionGoals.calories', 2200); }); it('returns profile preferences with the authenticated user', function () { @@ -98,25 +95,25 @@ it('updates profile preferences for the authenticated user', function () { Sanctum::actingAs($user); - $this->patchJson('/api/auth/me', [ + $this->patchJson('/api/nutrition-profile', [ 'physicalActivityLevel' => PhysicalActivityLevel::MODERATELY_ACTIVE->value, 'weightGoal' => WeightGoal::MAINTAIN_WEIGHT->value, 'pacePreference' => PacePreference::NORMAL->value, 'sex' => UserSex::MAN->value, 'dateOfBirth' => '2003-04-24', - 'locale' => 'fr-FR', + 'heightCm' => 180, + 'weightKg' => 75, + 'targetWeightKg' => null, ]) ->assertOk() ->assertJsonPath('data.physicalActivityLevel', PhysicalActivityLevel::MODERATELY_ACTIVE->value) ->assertJsonPath('data.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value) ->assertJsonPath('data.pacePreference', PacePreference::NORMAL->value) ->assertJsonPath('data.sex', UserSex::MAN->value) - ->assertJsonPath('data.locale', 'fr') ->assertJsonPath('data.dateOfBirth', '2003-04-24'); $this->assertDatabaseHas('users', [ 'id' => $user->id, - 'locale' => 'fr', 'physical_activity_level' => PhysicalActivityLevel::MODERATELY_ACTIVE->value, 'weight_goal' => WeightGoal::MAINTAIN_WEIGHT->value, 'pace_preference' => PacePreference::NORMAL->value, @@ -129,13 +126,14 @@ it('updates profile preferences for the authenticated user', function () { it('validates profile preferences', function () { Sanctum::actingAs(User::factory()->create()); - $this->patchJson('/api/auth/me', [ + $this->patchJson('/api/nutrition-profile', [ 'physicalActivityLevel' => 'daily', 'weightGoal' => 'bulk', 'pacePreference' => 'urgent', 'sex' => 'x', - 'locale' => 'es', 'dateOfBirth' => 'tomorrow', + 'heightCm' => 20, + 'weightKg' => 5, ]) ->assertUnprocessable() ->assertJsonValidationErrors([ @@ -143,7 +141,8 @@ it('validates profile preferences', function () { 'weightGoal', 'pacePreference', 'sex', - 'locale', 'dateOfBirth', + 'heightCm', + 'weightKg', ]); }); diff --git a/tests/Feature/WeeklyReviewTest.php b/tests/Feature/WeeklyReviewTest.php new file mode 100644 index 0000000..69b2715 --- /dev/null +++ b/tests/Feature/WeeklyReviewTest.php @@ -0,0 +1,132 @@ +create(); + Sanctum::actingAs($user); + + $this->getJson('/api/weekly-reviews/current') + ->assertOk() + ->assertJsonPath('data.periodStart', '2026-08-10') + ->assertJsonPath('data.periodEnd', '2026-08-16') + ->assertJsonPath('data.status', WeeklyReviewStatus::INSUFFICIENT_DATA->value) + ->assertJsonPath('data.recommendedCalorieAdjustment', 0); + + $this->assertDatabaseCount('nutrition_plans', 1); +}); + +it('proposes and applies a small confirmed adjustment', function () { + $user = User::factory()->create([ + 'weight_goal' => WeightGoal::LOSE_WEIGHT, + 'pace_preference' => PacePreference::NORMAL, + ]); + $user->weightEntries()->delete(); + + foreach ([ + ['2026-08-03 07:00:00', 70.0], + ['2026-08-07 07:00:00', 69.98], + ['2026-08-12 07:00:00', 69.95], + ['2026-08-16 07:00:00', 69.90], + ] as [$measuredAt, $weightKg]) { + $user->weightEntries()->create([ + 'weight_kg' => $weightKg, + 'measured_at' => $measuredAt, + ]); + } + + foreach (range(10, 14) as $day) { + MealPosts::factory()->for($user, 'user')->create([ + 'calories' => 2200, + 'proteins' => 112, + 'carbs' => 286, + 'fats' => 61, + 'eaten_at' => "2026-08-{$day} 12:00:00", + ]); + } + + Sanctum::actingAs($user); + + $reviewResponse = $this->getJson('/api/weekly-reviews/current') + ->assertOk() + ->assertJsonPath('data.status', WeeklyReviewStatus::ADJUSTMENT_RECOMMENDED->value) + ->assertJsonPath('data.recommendedCalorieAdjustment', -100); + + $reviewId = $reviewResponse->json('data.id'); + + $this->postJson("/api/weekly-reviews/{$reviewId}/accept") + ->assertCreated() + ->assertJsonPath('data.source', 'weekly_adjustment'); + + $this->assertDatabaseCount('nutrition_plans', 2); + $this->assertDatabaseHas('weekly_reviews', [ + 'id' => $reviewId, + 'recommended_calorie_adjustment' => -100, + ]); + + expect($user->weeklyReviews()->findOrFail($reviewId)->accepted_at)->not->toBeNull(); +}); + +it('does not allow accepting another users review', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $review = $otherUser->weeklyReviews()->create([ + 'period_start' => '2026-08-10', + 'period_end' => '2026-08-16', + 'status' => WeeklyReviewStatus::ADJUSTMENT_RECOMMENDED, + 'recommended_calorie_adjustment' => -100, + 'message_code' => 'WEEKLY_REVIEW_ADJUSTMENT', + 'metrics' => [], + 'generated_at' => now(), + ]); + Sanctum::actingAs($user); + + $this->postJson("/api/weekly-reviews/{$review->id}/accept")->assertNotFound(); +}); + +it('rejects an adjustment generated for a plan that is no longer active', function () { + $user = User::factory()->create(); + $previousPlan = $user->nutritionPlans()->where('is_active', true)->firstOrFail(); + $review = $user->weeklyReviews()->create([ + 'nutrition_plan_id' => $previousPlan->getKey(), + 'period_start' => '2026-08-10', + 'period_end' => '2026-08-16', + 'status' => WeeklyReviewStatus::ADJUSTMENT_RECOMMENDED, + 'recommended_calorie_adjustment' => -100, + 'message_code' => 'WEEKLY_REVIEW_ADJUSTMENT', + 'metrics' => [], + 'generated_at' => now(), + ]); + $previousPlan->update([ + 'is_active' => false, + 'effective_until' => now(), + ]); + NutritionPlan::factory()->for($user)->create([ + 'is_active' => true, + 'effective_from' => now()->addMinute(), + ]); + Sanctum::actingAs($user); + + $this->postJson("/api/weekly-reviews/{$review->id}/accept") + ->assertStatus(409); + + expect($review->fresh()->accepted_at)->toBeNull(); +}); diff --git a/tests/Feature/WeightEntryTest.php b/tests/Feature/WeightEntryTest.php new file mode 100644 index 0000000..cdbef11 --- /dev/null +++ b/tests/Feature/WeightEntryTest.php @@ -0,0 +1,46 @@ +create(); + Sanctum::actingAs($user); + + $this->postJson('/api/weight-entries', [ + 'weightKg' => 69.35, + 'measuredAt' => '2026-08-10T07:30:00Z', + ]) + ->assertCreated() + ->assertJsonPath('data.weightKg', 69.35); + + $this->getJson('/api/weight-entries?days=365') + ->assertOk() + ->assertJsonPath('data.0.weightKg', 70) + ->assertJsonPath('data.1.weightKg', 69.35); +}); + +it('does not allow deleting another users weight entry', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $entry = WeightEntry::factory()->for($otherUser)->create(); + Sanctum::actingAs($user); + + $this->deleteJson("/api/weight-entries/{$entry->id}")->assertNotFound(); + $this->assertModelExists($entry); +}); + +it('validates weight measurements', function () { + Sanctum::actingAs(User::factory()->create()); + + $this->postJson('/api/weight-entries', [ + 'weightKg' => 10, + 'measuredAt' => now()->addDay()->toISOString(), + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['weightKg', 'measuredAt']); +}); diff --git a/tests/Unit/NutritionPlanCalculatorTest.php b/tests/Unit/NutritionPlanCalculatorTest.php new file mode 100644 index 0000000..7257323 --- /dev/null +++ b/tests/Unit/NutritionPlanCalculatorTest.php @@ -0,0 +1,70 @@ +calculate( + weightKg: 80, + heightCm: 180, + dateOfBirth: CarbonImmutable::parse('1996-08-14'), + sex: UserSex::MAN, + activityLevel: PhysicalActivityLevel::MODERATELY_ACTIVE, + weightGoal: WeightGoal::MAINTAIN_WEIGHT, + pacePreference: PacePreference::NORMAL, + ); + + expect($plan) + ->formula_version->toBe('mifflin_st_jeor_v1') + ->resting_metabolism->toBe(1780) + ->maintenance_calories->toBe(2760) + ->calorie_adjustment_percent->toBe(0.0) + ->proteins->toBe(128) + ->fats->toBe(77) + ->carbs->toBe(389) + ->calories->toBe(2761) + ->and(($plan['proteins'] * 4) + ($plan['carbs'] * 4) + ($plan['fats'] * 9)) + ->toBe($plan['calories']); +}); + +it('applies bounded loss and gain adjustments', function (WeightGoal $goal, PacePreference $pace, float $expectedAdjustment) { + $plan = (new NutritionPlanCalculator)->calculate( + weightKg: 60, + heightCm: 165, + dateOfBirth: CarbonImmutable::parse('1990-01-01'), + sex: UserSex::WOMAN, + activityLevel: PhysicalActivityLevel::LIGHTLY_ACTIVE, + weightGoal: $goal, + pacePreference: $pace, + ); + + expect($plan['calorie_adjustment_percent'])->toBe($expectedAdjustment) + ->and($plan['calories'])->toBeGreaterThanOrEqual(1200); +})->with([ + 'normal loss' => [WeightGoal::LOSE_WEIGHT, PacePreference::NORMAL, -0.15], + 'fast gain' => [WeightGoal::GAIN_WEIGHT, PacePreference::FAST, 0.15], +]); + +it('rejects a non binary metabolic calculation reference', function () { + (new NutritionPlanCalculator)->calculate( + weightKg: 70, + heightCm: 170, + dateOfBirth: CarbonImmutable::parse('1990-01-01'), + sex: UserSex::UNKNOWN, + activityLevel: PhysicalActivityLevel::SEDENTARY, + weightGoal: WeightGoal::MAINTAIN_WEIGHT, + pacePreference: PacePreference::SLOW, + ); +})->throws(InvalidArgumentException::class);