feat: add likes to posts
Laravel CI-CD / Tests Unitaires (push) Successful in 20s Details
Laravel CI-CD / Build & Push Docker (push) Successful in 40s Details
Laravel CI-CD / Deploy via Portainer (push) Successful in 2s Details

This commit is contained in:
Leon Morival 2026-05-18 21:16:02 +02:00
parent d706dd587a
commit bb409610a8
7 changed files with 156 additions and 5 deletions

View File

@ -9,6 +9,7 @@ use App\Http\Resources\MealPostsResource;
use App\Models\MealPosts; use App\Models\MealPosts;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -24,6 +25,10 @@ class MealPostController extends Controller
$mealPosts = MealPosts::query() $mealPosts = MealPosts::query()
->with('user:id,name,avatar_url') ->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') ->latest('eaten_at')
->paginate($perPage) ->paginate($perPage)
->withQueryString(); ->withQueryString();
@ -116,17 +121,15 @@ class MealPostController extends Controller
}); });
$mealPost->setRelation('user', $request->user()); $mealPost->setRelation('user', $request->user());
$mealPost->loadMissing('ingredients');
return (new MealPostsResource($mealPost)) return (new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)))
->response() ->response()
->setStatusCode(201); ->setStatusCode(201);
} }
public function show(Request $request, MealPosts $mealPost): MealPostsResource public function show(Request $request, MealPosts $mealPost): MealPostsResource
{ {
return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request));
return new MealPostsResource($mealPost->loadMissing('user:id,name,avatar_url', 'ingredients'));
} }
public function update(MealPostsRequest $request, MealPosts $mealPost): MealPostsResource public function update(MealPostsRequest $request, MealPosts $mealPost): MealPostsResource
@ -155,7 +158,21 @@ class MealPostController extends Controller
return $mealPost->refresh(); 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 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); 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 private function getValidatedDataWithImageUrl(MealPostsRequest $request): array
{ {
$data = $request->validated(); $data = $request->validated();

View File

@ -28,6 +28,8 @@ class MealPostsResource extends JsonResource
'type' => $this->type, 'type' => $this->type,
'aiGenerated' => $this->ai_generated, 'aiGenerated' => $this->ai_generated,
'dietType' => $this->diet_type, 'dietType' => $this->diet_type,
'likesCount' => (int) ($this->liked_by_users_count ?? 0),
'likedByMe' => (bool) ($this->liked_by_current_user ?? false),
'eatenAt' => $this->eaten_at, 'eatenAt' => $this->eaten_at,
'createdAt' => $this->created_at, 'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at, 'updatedAt' => $this->updated_at,

View File

@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@ -48,6 +49,12 @@ class MealPosts extends Model
return $this->hasMany(MealPostIngredients::class)->orderBy('position'); 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 protected function casts(): array
{ {
return [ return [

View File

@ -7,6 +7,7 @@ use Filament\Models\Contracts\FilamentUser;
use Filament\Panel; use Filament\Panel;
use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@ -72,6 +73,12 @@ class User extends Authenticatable implements FilamentUser
return $this->hasMany(MealPosts::class); 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 public function routeNotificationForExpoPush(): array
{ {
return $this->deviceTokens() return $this->deviceTokens()

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('meal_post_likes', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -6,6 +6,7 @@ use App\Http\Controllers\MealImageAnalysisController;
use App\Http\Controllers\MealPostController; use App\Http\Controllers\MealPostController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
//Auth
Route::prefix('auth')->group(function (): void { Route::prefix('auth')->group(function (): void {
Route::post('register', [AuthController::class, 'register']); Route::post('register', [AuthController::class, 'register']);
Route::post('login', [AuthController::class, 'login']); Route::post('login', [AuthController::class, 'login']);
@ -24,10 +25,13 @@ Route::middleware('auth:sanctum')->group(function (): void {
Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); Route::get('test-notifs', [DeviceTokenController::class, 'notifs']);
}); });
//Meals
Route::middleware('auth:sanctum')->group(function (): void { Route::middleware('auth:sanctum')->group(function (): void {
Route::get('meal-posts/stats', [MealPostController::class, 'stats']); Route::get('meal-posts/stats', [MealPostController::class, 'stats']);
Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']); Route::get('meal-posts/calendar', [MealPostController::class, 'calendar']);
Route::post('meal-posts/analyze-image', [MealImageAnalysisController::class, 'store']); 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::apiResource('meal-posts', MealPostController::class)->except(['update']);
Route::put('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.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'); Route::patch('meal-posts/{mealPost}', [MealPostController::class, 'update'])->name('meal-posts.patch');

View File

@ -0,0 +1,79 @@
<?php
use App\Models\MealPosts;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('lets an authenticated user like and unlike a meal post', function () {
$owner = User::factory()->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();
});