diff --git a/app/Http/Controllers/MealPostController.php b/app/Http/Controllers/MealPostController.php index f9d4fb0..8db51ef 100644 --- a/app/Http/Controllers/MealPostController.php +++ b/app/Http/Controllers/MealPostController.php @@ -9,6 +9,7 @@ use App\Http\Resources\MealPostsResource; use App\Models\MealPosts; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -24,6 +25,10 @@ class MealPostController extends Controller $mealPosts = MealPosts::query() ->with('user:id,name,avatar_url') + ->withCount('likedByUsers') + ->withExists([ + 'likedByUsers as liked_by_current_user' => fn (Builder $query): Builder => $query->whereKey($request->user()->getKey()), + ]) ->latest('eaten_at') ->paginate($perPage) ->withQueryString(); @@ -116,17 +121,15 @@ class MealPostController extends Controller }); $mealPost->setRelation('user', $request->user()); - $mealPost->loadMissing('ingredients'); - return (new MealPostsResource($mealPost)) + return (new MealPostsResource($this->loadMealPostForResponse($mealPost, $request))) ->response() ->setStatusCode(201); } public function show(Request $request, MealPosts $mealPost): MealPostsResource { - - return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url', 'ingredients')); + return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); } public function update(MealPostsRequest $request, MealPosts $mealPost): MealPostsResource @@ -155,7 +158,21 @@ class MealPostController extends Controller return $mealPost->refresh(); }); - return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url', 'ingredients')); + return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); + } + + public function like(Request $request, MealPosts $mealPost): MealPostsResource + { + $mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]); + + return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); + } + + public function unlike(Request $request, MealPosts $mealPost): MealPostsResource + { + $mealPost->likedByUsers()->detach($request->user()->getKey()); + + return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); } public function destroy(Request $request, MealPosts $mealPost): Response @@ -173,6 +190,16 @@ class MealPostController extends Controller abort_unless($mealPost->user_id === $request->user()->getKey(), 404); } + private function loadMealPostForResponse(MealPosts $mealPost, Request $request): MealPosts + { + return $mealPost + ->loadMissing('user:id,name,avatar_url', 'ingredients') + ->loadCount('likedByUsers') + ->loadExists([ + 'likedByUsers as liked_by_current_user' => fn (Builder $query): Builder => $query->whereKey($request->user()->getKey()), + ]); + } + private function getValidatedDataWithImageUrl(MealPostsRequest $request): array { $data = $request->validated(); diff --git a/app/Http/Resources/MealPostsResource.php b/app/Http/Resources/MealPostsResource.php index 8666c94..4b54203 100644 --- a/app/Http/Resources/MealPostsResource.php +++ b/app/Http/Resources/MealPostsResource.php @@ -28,6 +28,8 @@ class MealPostsResource extends JsonResource 'type' => $this->type, 'aiGenerated' => $this->ai_generated, 'dietType' => $this->diet_type, + 'likesCount' => (int) ($this->liked_by_users_count ?? 0), + 'likedByMe' => (bool) ($this->liked_by_current_user ?? false), 'eatenAt' => $this->eaten_at, 'createdAt' => $this->created_at, 'updatedAt' => $this->updated_at, diff --git a/app/Models/MealPosts.php b/app/Models/MealPosts.php index 3bf7059..a69de4e 100644 --- a/app/Models/MealPosts.php +++ b/app/Models/MealPosts.php @@ -9,6 +9,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\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -48,6 +49,12 @@ class MealPosts extends Model return $this->hasMany(MealPostIngredients::class)->orderBy('position'); } + public function likedByUsers(): BelongsToMany + { + return $this->belongsToMany(User::class, 'meal_post_likes', 'meal_posts_id', 'user_id') + ->withTimestamps(); + } + protected function casts(): array { return [ diff --git a/app/Models/User.php b/app/Models/User.php index 4a15776..55fd2ef 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -72,6 +73,12 @@ class User extends Authenticatable implements FilamentUser return $this->hasMany(MealPosts::class); } + public function likedMealPosts(): BelongsToMany + { + return $this->belongsToMany(MealPosts::class, 'meal_post_likes', 'user_id', 'meal_posts_id') + ->withTimestamps(); + } + public function routeNotificationForExpoPush(): array { return $this->deviceTokens() diff --git a/database/migrations/2026_05_18_190757_create_meal_post_likes_table.php b/database/migrations/2026_05_18_190757_create_meal_post_likes_table.php new file mode 100644 index 0000000..2842a4b --- /dev/null +++ b/database/migrations/2026_05_18_190757_create_meal_post_likes_table.php @@ -0,0 +1,25 @@ +foreignUlid('meal_posts_id')->constrained('meal_posts')->cascadeOnDelete(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['meal_posts_id', 'user_id']); + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('meal_post_likes'); + } +}; diff --git a/routes/api.php b/routes/api.php index 39453f4..c031fe7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; use Illuminate\Support\Facades\Route; +//Auth Route::prefix('auth')->group(function (): void { Route::post('register', [AuthController::class, 'register']); Route::post('login', [AuthController::class, 'login']); @@ -24,10 +25,13 @@ Route::middleware('auth:sanctum')->group(function (): void { Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); }); +//Meals Route::middleware('auth:sanctum')->group(function (): void { Route::get('meal-posts/stats', [MealPostController::class, 'stats']); Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']); Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']); + Route::post('meal-posts/{mealPost}/like', [MealPostController::class, 'like'])->name('meal-posts.like'); + Route::delete('meal-posts/{mealPost}/like', [MealPostController::class, 'unlike'])->name('meal-posts.unlike'); Route::apiResource('meal-posts', MealPostController::class)->except(['update']); Route::put('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.update'); Route::patch('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.patch'); diff --git a/tests/Feature/MealPostLikeTest.php b/tests/Feature/MealPostLikeTest.php new file mode 100644 index 0000000..2f3e091 --- /dev/null +++ b/tests/Feature/MealPostLikeTest.php @@ -0,0 +1,79 @@ +create(); + $otherUser = User::factory()->create(); + $user = User::factory()->create(); + $mealPost = MealPosts::factory()->for($owner, 'user')->create(); + + $mealPost->likedByUsers()->attach($otherUser->getKey()); + + Sanctum::actingAs($user); + + $this->postJson("/api/meal-posts/{$mealPost->id}/like") + ->assertOk() + ->assertJsonPath('data.likesCount', 2) + ->assertJsonPath('data.likedByMe', true); + + $this->postJson("/api/meal-posts/{$mealPost->id}/like") + ->assertOk() + ->assertJsonPath('data.likesCount', 2) + ->assertJsonPath('data.likedByMe', true); + + expect(DB::table('meal_post_likes')->where('meal_posts_id', $mealPost->id)->count())->toBe(2); + + $this->deleteJson("/api/meal-posts/{$mealPost->id}/like") + ->assertOk() + ->assertJsonPath('data.likesCount', 1) + ->assertJsonPath('data.likedByMe', false); + + $this->assertDatabaseMissing('meal_post_likes', [ + 'meal_posts_id' => $mealPost->id, + 'user_id' => $user->id, + ]); + + $this->assertDatabaseHas('meal_post_likes', [ + 'meal_posts_id' => $mealPost->id, + 'user_id' => $otherUser->id, + ]); +}); + +it('returns like metadata when listing meal posts', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $likedMealPost = MealPosts::factory()->create([ + 'eaten_at' => now()->addMinute(), + ]); + $unlikedMealPost = MealPosts::factory()->create([ + 'eaten_at' => now(), + ]); + + $likedMealPost->likedByUsers()->attach([$user->getKey(), $otherUser->getKey()]); + $unlikedMealPost->likedByUsers()->attach($otherUser->getKey()); + + Sanctum::actingAs($user); + + $this->getJson('/api/meal-posts') + ->assertOk() + ->assertJsonPath('data.0.id', $likedMealPost->id) + ->assertJsonPath('data.0.likesCount', 2) + ->assertJsonPath('data.0.likedByMe', true) + ->assertJsonPath('data.1.id', $unlikedMealPost->id) + ->assertJsonPath('data.1.likesCount', 1) + ->assertJsonPath('data.1.likedByMe', false); +}); + +it('requires authentication to like a meal post', function () { + $mealPost = MealPosts::factory()->create(); + + $this->postJson("/api/meal-posts/{$mealPost->id}/like") + ->assertUnauthorized(); +});