diff --git a/app/Enums/IngredientUnit.php b/app/Enums/IngredientUnit.php new file mode 100644 index 0000000..43f25f1 --- /dev/null +++ b/app/Enums/IngredientUnit.php @@ -0,0 +1,16 @@ +getValidatedDataWithImageUrl($request), - 'user_id' => $request->user()->getKey(), - ]); + $data = $this->getValidatedDataWithImageUrl($request); + $ingredients = $data['ingredients'] ?? []; + + unset($data['ingredients']); + + $mealPost = DB::transaction(function () use ($data, $ingredients, $request): MealPosts { + $mealPost = MealPosts::create([ + ...$data, + 'user_id' => $request->user()->getKey(), + ]); + + if ($ingredients !== []) { + $mealPost->ingredients()->createMany($ingredients); + } + + return $mealPost; + }); $mealPost->setRelation('user', $request->user()); + $mealPost->loadMissing('ingredients'); return (new MealPostsResource($mealPost)) ->response() @@ -82,16 +97,36 @@ class MealPostController extends Controller { $this->abortIfNotOwner($request, $mealPost); - return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url')); + return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url', 'ingredients')); } public function update(MealPostsRequest $request, MealPosts $mealPost): MealPostsResource { $this->abortIfNotOwner($request, $mealPost); - $mealPost->update($this->getValidatedDataWithImageUrl($request)); + $data = $this->getValidatedDataWithImageUrl($request); + $shouldSyncIngredients = array_key_exists('ingredients', $data); + $ingredients = $data['ingredients'] ?? []; - return new MealPostsResource($mealPost->refresh()->loadMissing('user:id,name,avatar_url')); + unset($data['ingredients']); + + $mealPost = DB::transaction(function () use ($data, $ingredients, $mealPost, $shouldSyncIngredients): MealPosts { + if ($data !== []) { + $mealPost->update($data); + } + + if ($shouldSyncIngredients) { + $mealPost->ingredients()->delete(); + + if ($ingredients !== []) { + $mealPost->ingredients()->createMany($ingredients); + } + } + + return $mealPost->refresh(); + }); + + return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url', 'ingredients')); } public function destroy(Request $request, MealPosts $mealPost): Response diff --git a/app/Http/Requests/MealPostIngredientsRequest.php b/app/Http/Requests/MealPostIngredientsRequest.php new file mode 100644 index 0000000..fa0b13e --- /dev/null +++ b/app/Http/Requests/MealPostIngredientsRequest.php @@ -0,0 +1,25 @@ + ['required', 'exists:meal_posts,id'], + 'position' => ['required', 'integer'], + 'ingredient' => ['required', 'string'], + 'unit' => ['required', Rule::enum(IngredientUnit::class)], + ]; + } + + public function authorize(): bool + { + return true; + } +} diff --git a/app/Http/Requests/MealPostsRequest.php b/app/Http/Requests/MealPostsRequest.php index 392d5ab..2d3525b 100644 --- a/app/Http/Requests/MealPostsRequest.php +++ b/app/Http/Requests/MealPostsRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Enums\IngredientUnit; use App\Enums\MealPostType; use App\Enums\MealPostVisibility; use Illuminate\Foundation\Http\FormRequest; @@ -25,6 +26,11 @@ class MealPostsRequest extends FormRequest 'eaten_at' => [$isCreating ? 'required' : 'sometimes', 'date'], 'type' => [$isCreating ? 'required' : 'sometimes', Rule::enum(MealPostType::class)], 'visibility' => ['sometimes', Rule::enum(MealPostVisibility::class)], + 'ingredients' => ['sometimes', 'array'], + 'ingredients.*' => ['required', 'array:position,ingredient,unit'], + 'ingredients.*.position' => ['required', 'integer', 'min:0'], + 'ingredients.*.ingredient' => ['required', 'string', 'max:255'], + 'ingredients.*.unit' => ['required', Rule::enum(IngredientUnit::class)], ]; } diff --git a/app/Http/Resources/MealPostIngredientsResource.php b/app/Http/Resources/MealPostIngredientsResource.php new file mode 100644 index 0000000..c5980d3 --- /dev/null +++ b/app/Http/Resources/MealPostIngredientsResource.php @@ -0,0 +1,27 @@ + $this->id, + 'position' => $this->position, + 'ingredient' => $this->ingredient, + 'unit' => $this->unit, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + + 'meal_posts_id' => $this->meal_posts_id, + + 'mealPosts' => new MealPostsResource($this->whenLoaded('mealPosts')), + ]; + } +} diff --git a/app/Http/Resources/MealPostsResource.php b/app/Http/Resources/MealPostsResource.php index ffbe0f1..dc9e9eb 100644 --- a/app/Http/Resources/MealPostsResource.php +++ b/app/Http/Resources/MealPostsResource.php @@ -29,6 +29,7 @@ class MealPostsResource extends JsonResource 'eatenAt' => $this->eaten_at, 'createdAt' => $this->created_at, 'updatedAt' => $this->updated_at, + 'ingredients' => MealPostIngredientsResource::collection($this->whenLoaded('ingredients')), 'user' => [ 'name' => $user?->name, diff --git a/app/Models/MealPostIngredients.php b/app/Models/MealPostIngredients.php new file mode 100644 index 0000000..619f5a7 --- /dev/null +++ b/app/Models/MealPostIngredients.php @@ -0,0 +1,29 @@ +belongsTo(MealPosts::class); + } + + protected function casts(): array + { + return [ + 'unit' => IngredientUnit::class, + ]; + } +} diff --git a/app/Models/MealPosts.php b/app/Models/MealPosts.php index faeb47d..755e1ea 100644 --- a/app/Models/MealPosts.php +++ b/app/Models/MealPosts.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class MealPosts extends Model @@ -37,12 +38,17 @@ class MealPosts extends Model return $this->belongsTo(User::class); } + public function ingredients(): HasMany + { + return $this->hasMany(MealPostIngredients::class)->orderBy('position'); + } + protected function casts(): array { return [ 'eaten_at' => 'datetime', 'visibility' => MealPostVisibility::class, - 'type' => MealPostType::class + 'type' => MealPostType::class, ]; } } diff --git a/app/Policies/MealPostIngredientsPolicy.php b/app/Policies/MealPostIngredientsPolicy.php new file mode 100644 index 0000000..f69fcef --- /dev/null +++ b/app/Policies/MealPostIngredientsPolicy.php @@ -0,0 +1,48 @@ + 'openai', + 'default' => 'deepseek', 'default_for_images' => 'gemini', 'default_for_audio' => 'openai', 'default_for_transcription' => 'openai', - 'default_for_embeddings' => 'openai', + 'default_for_embeddings' => 'deepseek', 'default_for_reranking' => 'cohere', /* diff --git a/database/migrations/2026_05_18_122038_create_meal_post_ingredients_table.php b/database/migrations/2026_05_18_122038_create_meal_post_ingredients_table.php new file mode 100644 index 0000000..9e30c69 --- /dev/null +++ b/database/migrations/2026_05_18_122038_create_meal_post_ingredients_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignUlid('meal_posts_id')->constrained('meal_posts')->cascadeOnDelete(); + $table->integer('position'); + $table->string('ingredient'); + $table->string('unit'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('meal_post_ingredients'); + } +}; diff --git a/tests/Feature/MealPostIngredientsTest.php b/tests/Feature/MealPostIngredientsTest.php new file mode 100644 index 0000000..95199a4 --- /dev/null +++ b/tests/Feature/MealPostIngredientsTest.php @@ -0,0 +1,176 @@ +create(); + + Sanctum::actingAs($user); + + $response = $this->postJson('/api/meal-posts', [ + 'image_url' => 'https://example.com/meal.jpg', + 'title' => 'Bowl saumon avocat', + 'eaten_at' => '2026-05-16T12:00:00.000Z', + 'type' => MealPostType::LUNCH->value, + 'ingredients' => [ + [ + 'position' => 1, + 'ingredient' => 'Riz', + 'unit' => IngredientUnit::GRAM->value, + ], + [ + 'position' => 2, + 'ingredient' => 'Saumon', + 'unit' => IngredientUnit::GRAM->value, + ], + ], + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.ingredients.0.ingredient', 'Riz') + ->assertJsonPath('data.ingredients.0.unit', IngredientUnit::GRAM->value) + ->assertJsonPath('data.ingredients.1.ingredient', 'Saumon'); + + $this->assertDatabaseHas('meal_post_ingredients', [ + 'meal_posts_id' => $response->json('data.id'), + 'position' => 1, + 'ingredient' => 'Riz', + 'unit' => IngredientUnit::GRAM->value, + ]); +}); + +it('validates meal post ingredients', function () { + Sanctum::actingAs(User::factory()->create()); + + $response = $this->postJson('/api/meal-posts', [ + 'image_url' => 'https://example.com/meal.jpg', + 'title' => 'Bowl saumon avocat', + 'eaten_at' => '2026-05-16T12:00:00.000Z', + 'type' => MealPostType::LUNCH->value, + 'ingredients' => [ + [ + 'position' => -1, + 'ingredient' => '', + 'unit' => 'cup', + ], + ], + ]); + + $response + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'ingredients.0.position', + 'ingredients.0.ingredient', + 'ingredients.0.unit', + ]); +}); + +it('replaces meal post ingredients when updating a meal post', function () { + $user = User::factory()->create(); + $mealPost = MealPosts::factory()->for($user, 'user')->create(); + $mealPost->ingredients()->createMany([ + [ + 'position' => 1, + 'ingredient' => 'Riz', + 'unit' => IngredientUnit::GRAM, + ], + [ + 'position' => 2, + 'ingredient' => 'Saumon', + 'unit' => IngredientUnit::GRAM, + ], + ]); + + Sanctum::actingAs($user); + + $response = $this->patchJson("/api/meal-posts/{$mealPost->id}", [ + 'ingredients' => [ + [ + 'position' => 1, + 'ingredient' => 'Pates', + 'unit' => IngredientUnit::GRAM->value, + ], + [ + 'position' => 2, + 'ingredient' => 'Huile olive', + 'unit' => IngredientUnit::TABLESPOON->value, + ], + ], + ]); + + $response + ->assertOk() + ->assertJsonPath('data.ingredients.0.ingredient', 'Pates') + ->assertJsonPath('data.ingredients.1.unit', IngredientUnit::TABLESPOON->value); + + $this->assertDatabaseMissing('meal_post_ingredients', [ + 'meal_posts_id' => $mealPost->id, + 'ingredient' => 'Riz', + ]); + $this->assertDatabaseHas('meal_post_ingredients', [ + 'meal_posts_id' => $mealPost->id, + 'position' => 1, + 'ingredient' => 'Pates', + 'unit' => IngredientUnit::GRAM->value, + ]); +}); + +it('keeps existing ingredients when updating a meal post without ingredients', function () { + $user = User::factory()->create(); + $mealPost = MealPosts::factory()->for($user, 'user')->create([ + 'title' => 'Ancien titre', + ]); + $mealPost->ingredients()->create([ + 'position' => 1, + 'ingredient' => 'Riz', + 'unit' => IngredientUnit::GRAM, + ]); + + Sanctum::actingAs($user); + + $response = $this->patchJson("/api/meal-posts/{$mealPost->id}", [ + 'title' => 'Nouveau titre', + ]); + + $response + ->assertOk() + ->assertJsonPath('data.title', 'Nouveau titre') + ->assertJsonPath('data.ingredients.0.ingredient', 'Riz'); + + $this->assertDatabaseHas('meal_post_ingredients', [ + 'meal_posts_id' => $mealPost->id, + 'ingredient' => 'Riz', + ]); +}); + +it('removes meal post ingredients when updating with an empty ingredients list', function () { + $user = User::factory()->create(); + $mealPost = MealPosts::factory()->for($user, 'user')->create(); + $mealPost->ingredients()->create([ + 'position' => 1, + 'ingredient' => 'Riz', + 'unit' => IngredientUnit::GRAM, + ]); + + Sanctum::actingAs($user); + + $response = $this->patchJson("/api/meal-posts/{$mealPost->id}", [ + 'ingredients' => [], + ]); + + $response + ->assertOk() + ->assertJsonCount(0, 'data.ingredients'); + + $this->assertDatabaseMissing('meal_post_ingredients', [ + 'meal_posts_id' => $mealPost->id, + ]); +});