diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 78a6780..d13c7ba 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -80,6 +80,7 @@ class AuthController extends Controller { $user = $request->user(); $data = $request->validated(); + $nutritionGoals = $data['nutritionGoals'] ?? null; if ($request->hasFile('avatar')) { if ($user->avatar_url) { @@ -91,6 +92,14 @@ class AuthController extends Controller } unset($data['avatar']); + unset($data['nutritionGoals']); + + if (is_array($nutritionGoals)) { + $data['daily_calorie_goal'] = $nutritionGoals['calories']; + $data['daily_protein_goal'] = $nutritionGoals['proteins']; + $data['daily_carbs_goal'] = $nutritionGoals['carbs']; + $data['daily_fats_goal'] = $nutritionGoals['fats']; + } if (! empty($data)) { $user->update($data); diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php index d7ff0db..8ff7971 100644 --- a/app/Http/Requests/UpdateUserRequest.php +++ b/app/Http/Requests/UpdateUserRequest.php @@ -15,6 +15,11 @@ class UpdateUserRequest extends FormRequest { return [ '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'], ]; } @@ -22,6 +27,10 @@ class UpdateUserRequest extends FormRequest { return [ 'avatar.max' => "L'image ne doit pas dépasser 2 Mo.", + 'nutritionGoals.calories.integer' => 'Les calories doivent etre un nombre entier.', + 'nutritionGoals.*.required_with' => 'Renseigne tous les objectifs nutritionnels.', + 'nutritionGoals.*.min' => 'Les objectifs nutritionnels doivent etre positifs.', + 'nutritionGoals.*.max' => 'La valeur de cet objectif nutritionnel est trop elevee.', ]; } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 00118e7..3b905f0 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -20,6 +20,12 @@ class UserResource extends JsonResource 'email' => $this->email, 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, 'bio' => $this->bio, + '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, + ], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 5304958..2ff9510 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -28,6 +28,10 @@ class User extends Authenticatable implements FilamentUser 'password', 'avatar_url', 'bio', + 'daily_calorie_goal', + 'daily_protein_goal', + 'daily_carbs_goal', + 'daily_fats_goal', ]; /** @@ -50,6 +54,10 @@ class User extends Authenticatable implements FilamentUser return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'daily_calorie_goal' => 'integer', + 'daily_protein_goal' => 'float', + 'daily_carbs_goal' => 'float', + 'daily_fats_goal' => 'float', ]; } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 52153a5..3707b8a 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,10 @@ 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, 'remember_token' => Str::random(10), ]; } diff --git a/database/migrations/2026_05_16_150000_add_daily_nutrition_goals_to_users_table.php b/database/migrations/2026_05_16_150000_add_daily_nutrition_goals_to_users_table.php new file mode 100644 index 0000000..3765ff4 --- /dev/null +++ b/database/migrations/2026_05_16_150000_add_daily_nutrition_goals_to_users_table.php @@ -0,0 +1,30 @@ +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'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn([ + 'daily_calorie_goal', + 'daily_protein_goal', + 'daily_carbs_goal', + 'daily_fats_goal', + ]); + }); + } +}; diff --git a/deploy.yml b/deploy.yml new file mode 100644 index 0000000..c96362c --- /dev/null +++ b/deploy.yml @@ -0,0 +1,142 @@ +services: + app: + image: gitea.leonmorival.com/leon-morival/bemeal-api:latest + container_name: bemeal-app + restart: unless-stopped + + depends_on: + pgsql: + condition: service_healthy + redis: + condition: service_started + + environment: + APP_NAME: "BeMeal" + APP_ENV: production + APP_DEBUG: "false" + APP_KEY: ${APP_KEY} + APP_URL: ${APP_URL} + + APP_LOCALE: fr + + SERVER_NAME: ":80" + APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime" + + DB_CONNECTION: pgsql + DB_HOST: pgsql + DB_PORT: 5432 + DB_DATABASE: ${DB_DATABASE} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + + REDIS_HOST: redis + REDIS_CLIENT: predis + QUEUE_CONNECTION: redis + + SCOUT_DRIVER: meilisearch + MEILISEARCH_HOST: http://meilisearch:7700 + MEILISEARCH_KEY: ${MEILISEARCH_KEY} + + volumes: + - bemeal_storage:/var/www/html/storage/app + - bemeal_public:/var/www/html/storage/app/public + + networks: + - nginx + - internal + + horizon: + image: gitea.leonmorival.com/leon-morival/bemeal-api:latest + container_name: bemeal-horizon + restart: unless-stopped + + command: php artisan horizon + + depends_on: + pgsql: + condition: service_healthy + redis: + condition: service_started + + environment: + APP_ENV: production + APP_KEY: ${APP_KEY} + + DB_CONNECTION: pgsql + DB_HOST: pgsql + DB_PORT: 5432 + DB_DATABASE: ${DB_DATABASE} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + + REDIS_HOST: redis + REDIS_CLIENT: predis + QUEUE_CONNECTION: redis + + volumes: + - bemeal_storage:/var/www/html/storage/app + + networks: + - internal + + pgsql: + image: postgres:15-alpine + container_name: bemeal-pgsql + restart: unless-stopped + + environment: + POSTGRES_DB: ${DB_DATABASE} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}"] + interval: 5s + timeout: 5s + retries: 10 + + volumes: + - bemeal_pgsql:/var/lib/postgresql/data + + networks: + - internal + + redis: + image: redis:alpine + container_name: bemeal-redis + restart: unless-stopped + + volumes: + - bemeal_redis:/data + + networks: + - internal + + meilisearch: + image: getmeili/meilisearch:latest + container_name: bemeal-meilisearch + restart: unless-stopped + + environment: + MEILI_NO_ANALYTICS: "true" + MEILI_MASTER_KEY: ${MEILISEARCH_KEY} + + volumes: + - bemeal_meili:/meili_data + + networks: + - internal + +volumes: + bemeal_pgsql: + bemeal_redis: + bemeal_storage: + bemeal_public: + bemeal_meili: + +networks: + nginx: + external: true + + internal: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 67ca0e5..1c084ee 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,7 +1,7 @@ services: starter-api: - image: gitea.leonmorival.com/leon-morival/laravel-starter:latest - container_name: starter-starter-api-1 + image: gitea.leonmorival.com/leon-morival/bemeal-api:latest + container_name: bemeal-api restart: unless-stopped depends_on: pgsql: @@ -37,12 +37,12 @@ services: - storage-data:/var/www/html/storage/app - storage-public-data:/var/www/html/storage/app/public networks: - - nginx + - nginx-bemeal - internal pgsql: image: postgres:15-alpine - container_name: starter-pgsql-1 + container_name: bemeal-pgsql restart: unless-stopped environment: POSTGRES_DB: "${DB_DATABASE}" @@ -60,7 +60,7 @@ services: redis: image: redis:alpine - container_name: starter-redis-1 + container_name: bemeal-redis restart: unless-stopped volumes: - redis-data:/data @@ -68,11 +68,14 @@ services: - internal horizon: - image: gitea.leonmorival.com/leon-morival/laravel-starter:latest - container_name: starter-horizon-1 + image: gitea.leonmorival.com/leon-morival/bemeal-api:latest + container_name: bemeal-horizon restart: unless-stopped depends_on: - - redis + pgsql: + condition: service_healthy + redis: + condition: service_started environment: APP_ENV: "production" APP_KEY: "${APP_KEY}" @@ -93,18 +96,17 @@ services: adminer: image: adminer:latest - container_name: starter-adminer-1 + container_name: bemeal-adminer restart: unless-stopped environment: # Optionnel : définit PostgreSQL par défaut à la connexion ADMINER_DEFAULT_SERVER: pgsql networks: - internal - - nginx - +# - nginx meilisearch: image: getmeili/meilisearch:latest - container_name: starter-meilisearch-1 + container_name: bemeal-meilisearch restart: unless-stopped environment: MEILI_NO_ANALYTICS: "true" @@ -113,22 +115,23 @@ services: - meilisearch-data:/meili_data networks: - internal - - nginx +# - nginx volumes: pgsql-data: external: true - name: starter_pgsql_data + name: bemeal_pgsql_data redis-data: - name: starter_redis_data + name: bemeal_redis_data storage-data: + name: bemeal_storage_data storage-public-data: - name: starter_storage_public_data + name: bemeal_storage_public_data meilisearch-data: - name: starter_meilisearch_data + name: bemeal_meilisearch_data networks: - nginx: + nginx-bemeal: external: true internal: driver: bridge diff --git a/tests/Feature/UserNutritionGoalsTest.php b/tests/Feature/UserNutritionGoalsTest.php new file mode 100644 index 0000000..d92ca19 --- /dev/null +++ b/tests/Feature/UserNutritionGoalsTest.php @@ -0,0 +1,73 @@ +create([ + 'daily_calorie_goal' => 2400, + 'daily_protein_goal' => 140, + 'daily_carbs_goal' => 280, + 'daily_fats_goal' => 80, + ]); + + Sanctum::actingAs($user); + + $this->getJson('/api/auth/me') + ->assertOk() + ->assertJsonPath('data.nutritionGoals.calories', 2400) + ->assertJsonPath('data.nutritionGoals.proteins', 140) + ->assertJsonPath('data.nutritionGoals.carbs', 280) + ->assertJsonPath('data.nutritionGoals.fats', 80); +}); + +it('updates nutrition goals for the authenticated user', 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 + ->assertOk() + ->assertJsonPath('data.nutritionGoals.calories', 2200) + ->assertJsonPath('data.nutritionGoals.proteins', 135.5) + ->assertJsonPath('data.nutritionGoals.carbs', 260) + ->assertJsonPath('data.nutritionGoals.fats', 75.25); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'daily_calorie_goal' => 2200, + 'daily_protein_goal' => 135.5, + 'daily_carbs_goal' => 260, + 'daily_fats_goal' => 75.25, + ]); +}); + +it('validates nutrition goals', function () { + Sanctum::actingAs(User::factory()->create()); + + $this->patchJson('/api/auth/me', [ + 'nutritionGoals' => [ + 'calories' => -1, + 'proteins' => 'abc', + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'nutritionGoals.calories', + 'nutritionGoals.proteins', + 'nutritionGoals.carbs', + 'nutritionGoals.fats', + ]); +});