feat: add likes to posts
This commit is contained in:
parent
d706dd587a
commit
bb409610a8
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 [
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue