From cef3e29ddc71ca67d556b8d8219c4cf56d575810 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Tue, 11 Aug 2026 15:58:31 +0200 Subject: [PATCH] feat: add reciprocal user blocking --- app/Actions/BlockUser.php | 25 +++ app/Http/Controllers/FollowController.php | 10 +- app/Http/Controllers/MealPostController.php | 23 ++- .../Controllers/PostReviewsController.php | 32 +++- .../PublicUserProfileController.php | 1 + app/Http/Controllers/UserBlockController.php | 48 ++++++ app/Http/Resources/BlockedUserResource.php | 26 ++++ app/Models/User.php | 31 +++- app/Models/UserBlock.php | 28 ++++ database/factories/UserBlockFactory.php | 25 +++ ..._08_11_135119_create_user_blocks_table.php | 32 ++++ lang/en/api.php | 4 + lang/fr/api.php | 4 + routes/api.php | 4 + tests/Feature/UserBlockingTest.php | 144 ++++++++++++++++++ 15 files changed, 420 insertions(+), 17 deletions(-) create mode 100644 app/Actions/BlockUser.php create mode 100644 app/Http/Controllers/UserBlockController.php create mode 100644 app/Http/Resources/BlockedUserResource.php create mode 100644 app/Models/UserBlock.php create mode 100644 database/factories/UserBlockFactory.php create mode 100644 database/migrations/2026_08_11_135119_create_user_blocks_table.php create mode 100644 tests/Feature/UserBlockingTest.php diff --git a/app/Actions/BlockUser.php b/app/Actions/BlockUser.php new file mode 100644 index 0000000..45afd91 --- /dev/null +++ b/app/Actions/BlockUser.php @@ -0,0 +1,25 @@ +firstOrCreate([ + 'blocker_id' => $blocker->getKey(), + 'blocked_id' => $blocked->getKey(), + ]); + + $blocker->following()->detach($blocked->getKey()); + $blocker->followers()->detach($blocked->getKey()); + + return $userBlock; + }); + } +} diff --git a/app/Http/Controllers/FollowController.php b/app/Http/Controllers/FollowController.php index 6515c8a..b663c37 100644 --- a/app/Http/Controllers/FollowController.php +++ b/app/Http/Controllers/FollowController.php @@ -18,10 +18,12 @@ class FollowController extends Controller if ($authUser->id === $user->id) { return response()->json([ - 'message' => __('api.follows.cannot_follow_self') + 'message' => __('api.follows.cannot_follow_self'), ], 422); } + abort_if($authUser->hasBlockingRelationshipWith($user), 404); + // Default to Accepted as is_private doesn't exist in the DB yet $status = FollowStatus::Accepted; @@ -44,6 +46,8 @@ class FollowController extends Controller public function accept(FollowRequest $request, User $user): JsonResponse { + abort_if($request->user()->hasBlockingRelationshipWith($user), 404); + $updated = $request->user()->followers()->updateExistingPivot($user->id, [ 'status' => FollowStatus::Accepted, ]); @@ -55,6 +59,8 @@ class FollowController extends Controller public function reject(FollowRequest $request, User $user): JsonResponse { + abort_if($request->user()->hasBlockingRelationshipWith($user), 404); + $detached = $request->user()->followers()->detach($user->id); return response()->json([ @@ -70,6 +76,7 @@ class FollowController extends Controller $followers = $user->followers() ->wherePivot('status', FollowStatus::Accepted) + ->withoutBlockingRelationshipWith($request->user()) ->paginate($perPage); return FollowResource::collection($followers); @@ -83,6 +90,7 @@ class FollowController extends Controller $following = $user->following() ->wherePivot('status', FollowStatus::Accepted) + ->withoutBlockingRelationshipWith($request->user()) ->paginate($perPage); return FollowResource::collection($following); diff --git a/app/Http/Controllers/MealPostController.php b/app/Http/Controllers/MealPostController.php index dc2c3fc..8192585 100644 --- a/app/Http/Controllers/MealPostController.php +++ b/app/Http/Controllers/MealPostController.php @@ -137,7 +137,7 @@ class MealPostController extends Controller public function show(Request $request, MealPosts $mealPost): MealPostsResource { - $this->abortIfUnavailable($mealPost); + $this->abortIfUnavailable($request, $mealPost); return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request)); } @@ -173,7 +173,7 @@ class MealPostController extends Controller public function like(Request $request, MealPosts $mealPost): MealPostsResource { - $this->abortIfUnavailable($mealPost); + $this->abortIfUnavailable($request, $mealPost); $mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]); @@ -182,7 +182,7 @@ class MealPostController extends Controller public function unlike(Request $request, MealPosts $mealPost): MealPostsResource { - $this->abortIfUnavailable($mealPost); + $this->abortIfUnavailable($request, $mealPost); $mealPost->likedByUsers()->detach($request->user()->getKey()); @@ -204,10 +204,16 @@ class MealPostController extends Controller abort_unless($mealPost->user_id === $request->user()->getKey(), 404); } - private function abortIfUnavailable(MealPosts $mealPost): void + private function abortIfUnavailable(Request $request, MealPosts $mealPost): void { + $mealPost->loadMissing('user'); + $owner = $mealPost->user; + abort_if( - $mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(), + $mealPost->isHidden() + || $owner === null + || $owner->isSuspended() + || $request->user()?->hasBlockingRelationshipWith($owner), 404, ); } @@ -222,7 +228,12 @@ class MealPostController extends Controller { return MealPosts::query() ->whereNull('hidden_at') - ->whereHas('user', fn (Builder $query): Builder => $query->whereNull('suspended_at')) + ->whereHas( + 'user', + fn (Builder $query): Builder => $query + ->whereNull('suspended_at') + ->withoutBlockingRelationshipWith($request->user()) + ) ->with('user:id,name,avatar_url,account_verified_at'); } diff --git a/app/Http/Controllers/PostReviewsController.php b/app/Http/Controllers/PostReviewsController.php index 1a8d428..d5a7045 100644 --- a/app/Http/Controllers/PostReviewsController.php +++ b/app/Http/Controllers/PostReviewsController.php @@ -7,6 +7,7 @@ use App\Http\Resources\PostReviewsResource; use App\Models\MealPosts; use App\Models\PostReviews; use App\Notifications\MealPostCommentedNotification; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -16,11 +17,15 @@ class PostReviewsController extends Controller { public function index(Request $request, MealPosts $mealPost): AnonymousResourceCollection { - $this->abortIfUnavailable($mealPost); + $this->abortIfUnavailable($request, $mealPost); $perPage = max(1, min($request->integer('per_page', 15), 100)); $reviews = $mealPost->reviews() + ->whereHas( + 'user', + fn (Builder $query): Builder => $query->withoutBlockingRelationshipWith($request->user()) + ) ->with('user:id,name,avatar_url') ->latest() ->paginate($perPage) @@ -31,7 +36,7 @@ class PostReviewsController extends Controller public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse { - $this->abortIfUnavailable($mealPost); + $this->abortIfUnavailable($request, $mealPost); $userId = $request->user()->getKey(); @@ -60,7 +65,7 @@ class PostReviewsController extends Controller public function mine(Request $request, MealPosts $mealPost): JsonResponse|PostReviewsResource { - $this->abortIfUnavailable($mealPost); + $this->abortIfUnavailable($request, $mealPost); $postReview = $mealPost->reviews() ->where('user_id', $request->user()->getKey()) @@ -74,10 +79,15 @@ class PostReviewsController extends Controller return new PostReviewsResource($postReview); } - public function show(PostReviews $postReview): PostReviewsResource + public function show(Request $request, PostReviews $postReview): PostReviewsResource { - $postReview->loadMissing('mealPost.user'); - $this->abortIfUnavailable($postReview->mealPost); + $postReview->loadMissing('mealPost.user', 'user'); + $this->abortIfUnavailable($request, $postReview->mealPost); + abort_if( + $postReview->user === null + || $request->user()->hasBlockingRelationshipWith($postReview->user), + 404, + ); return new PostReviewsResource($postReview->loadMissing( 'mealPost.ingredients', @@ -110,10 +120,16 @@ class PostReviewsController extends Controller abort_unless($postReview->user_id === $request->user()->getKey(), 404); } - private function abortIfUnavailable(MealPosts $mealPost): void + private function abortIfUnavailable(Request $request, MealPosts $mealPost): void { + $mealPost->loadMissing('user'); + $owner = $mealPost->user; + abort_if( - $mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(), + $mealPost->isHidden() + || $owner === null + || $owner->isSuspended() + || $request->user()?->hasBlockingRelationshipWith($owner), 404, ); } diff --git a/app/Http/Controllers/PublicUserProfileController.php b/app/Http/Controllers/PublicUserProfileController.php index 12864f6..9a0064b 100644 --- a/app/Http/Controllers/PublicUserProfileController.php +++ b/app/Http/Controllers/PublicUserProfileController.php @@ -15,6 +15,7 @@ class PublicUserProfileController extends Controller public function show(Request $request, User $user): JsonResponse { abort_if($user->isSuspended(), 404); + abort_if($request->user()?->hasBlockingRelationshipWith($user), 404); $meals = $user->mealPosts() ->where('visibility', MealPostVisibility::Public->value) diff --git a/app/Http/Controllers/UserBlockController.php b/app/Http/Controllers/UserBlockController.php new file mode 100644 index 0000000..a943684 --- /dev/null +++ b/app/Http/Controllers/UserBlockController.php @@ -0,0 +1,48 @@ +integer('per_page', 15), 100)); + $blockedUsers = $request->user() + ->blockedUsers() + ->orderByPivotDesc('created_at') + ->paginate($perPage) + ->withQueryString(); + + return BlockedUserResource::collection($blockedUsers); + } + + public function store(Request $request, User $user, BlockUser $blockUser): JsonResponse + { + abort_if($request->user()->is($user), 422, __('api.blocks.cannot_block_self')); + + $blockUser->execute($request->user(), $user); + + return response()->json([ + 'message' => __('api.blocks.blocked'), + ]); + } + + public function destroy(Request $request, User $user): Response + { + UserBlock::query() + ->where('blocker_id', $request->user()->getKey()) + ->where('blocked_id', $user->getKey()) + ->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Resources/BlockedUserResource.php b/app/Http/Resources/BlockedUserResource.php new file mode 100644 index 0000000..42c2eca --- /dev/null +++ b/app/Http/Resources/BlockedUserResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 1f81c89..7f2afdd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -12,9 +12,11 @@ use Filament\Models\Contracts\HasAvatar; use Filament\Panel; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Translation\HasLocalePreference; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; @@ -152,20 +154,45 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->morphMany(Report::class, 'reportable'); } - public function followers(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function followers(): BelongsToMany { return $this->belongsToMany(self::class, 'follows', 'following_id', 'follower_id') ->withPivot('status') ->withTimestamps(); } - public function following(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function following(): BelongsToMany { return $this->belongsToMany(self::class, 'follows', 'follower_id', 'following_id') ->withPivot('status') ->withTimestamps(); } + public function blockedUsers(): BelongsToMany + { + return $this->belongsToMany(self::class, 'user_blocks', 'blocker_id', 'blocked_id') + ->withTimestamps(); + } + + public function blockedByUsers(): BelongsToMany + { + return $this->belongsToMany(self::class, 'user_blocks', 'blocked_id', 'blocker_id') + ->withTimestamps(); + } + + public function scopeWithoutBlockingRelationshipWith(Builder $query, User $user): Builder + { + return $query + ->whereDoesntHave('blockedByUsers', fn (Builder $blockedByQuery): Builder => $blockedByQuery->whereKey($user->getKey())) + ->whereDoesntHave('blockedUsers', fn (Builder $blockedQuery): Builder => $blockedQuery->whereKey($user->getKey())); + } + + public function hasBlockingRelationshipWith(User $user): bool + { + return $this->blockedUsers()->whereKey($user->getKey())->exists() + || $this->blockedByUsers()->whereKey($user->getKey())->exists(); + } + public function moderationCase(): MorphOne { return $this->morphOne(ModerationCase::class, 'caseable'); diff --git a/app/Models/UserBlock.php b/app/Models/UserBlock.php new file mode 100644 index 0000000..9838f44 --- /dev/null +++ b/app/Models/UserBlock.php @@ -0,0 +1,28 @@ + */ + use HasFactory; + + protected $fillable = [ + 'blocker_id', + 'blocked_id', + ]; + + public function blocker(): BelongsTo + { + return $this->belongsTo(User::class, 'blocker_id'); + } + + public function blocked(): BelongsTo + { + return $this->belongsTo(User::class, 'blocked_id'); + } +} diff --git a/database/factories/UserBlockFactory.php b/database/factories/UserBlockFactory.php new file mode 100644 index 0000000..49fd3c6 --- /dev/null +++ b/database/factories/UserBlockFactory.php @@ -0,0 +1,25 @@ + + */ +class UserBlockFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'blocker_id' => User::factory(), + 'blocked_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2026_08_11_135119_create_user_blocks_table.php b/database/migrations/2026_08_11_135119_create_user_blocks_table.php new file mode 100644 index 0000000..1f720e5 --- /dev/null +++ b/database/migrations/2026_08_11_135119_create_user_blocks_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignUlid('blocker_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('blocked_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['blocker_id', 'blocked_id']); + $table->index(['blocked_id', 'blocker_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_blocks'); + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index 7c2673b..c35c895 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -106,6 +106,10 @@ return [ 'rejected' => 'Follow request rejected.', 'not_found' => 'No request found.', ], + 'blocks' => [ + 'blocked' => 'User blocked.', + 'cannot_block_self' => 'You cannot block yourself.', + ], 'validation' => [ 'image_required' => 'Add an image to analyze.', 'image_file' => 'The file must be an image.', diff --git a/lang/fr/api.php b/lang/fr/api.php index 1619a5f..fb6bcac 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -106,6 +106,10 @@ return [ 'rejected' => 'Demande d’abonnement refusée.', 'not_found' => 'Aucune demande trouvée.', ], + 'blocks' => [ + 'blocked' => 'Utilisateur bloqué.', + 'cannot_block_self' => 'Vous ne pouvez pas vous bloquer vous-même.', + ], 'validation' => [ 'image_required' => 'Ajoute une image à analyser.', 'image_file' => 'Le fichier doit être une image.', diff --git a/routes/api.php b/routes/api.php index 2aa9b01..77ab3d2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,7 @@ use App\Http\Controllers\PostReviewsController; use App\Http\Controllers\PublicUserProfileController; use App\Http\Controllers\ReportController; use App\Http\Controllers\StravaController; +use App\Http\Controllers\UserBlockController; use App\Http\Controllers\WorkoutSessionController; use Illuminate\Support\Facades\Route; @@ -90,6 +91,9 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function // Meals Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void { Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show'); + Route::get('me/blocked-users', [UserBlockController::class, 'index'])->name('users.blocks.index'); + Route::post('users/{user}/block', [UserBlockController::class, 'store'])->middleware('throttle:engagement')->name('users.blocks.store'); + Route::delete('users/{user}/block', [UserBlockController::class, 'destroy'])->middleware('throttle:engagement')->name('users.blocks.destroy'); Route::post('users/{user}/follow', [FollowController::class, 'follow'])->middleware('throttle:engagement')->name('users.follow'); Route::delete('users/{user}/follow', [FollowController::class, 'unfollow'])->middleware('throttle:engagement')->name('users.unfollow'); Route::post('users/{user}/accept', [FollowController::class, 'accept'])->middleware('throttle:engagement')->name('users.accept'); diff --git a/tests/Feature/UserBlockingTest.php b/tests/Feature/UserBlockingTest.php new file mode 100644 index 0000000..55fad52 --- /dev/null +++ b/tests/Feature/UserBlockingTest.php @@ -0,0 +1,144 @@ +create(); + $blocked = User::factory()->create(); + + $blocker->following()->attach($blocked, ['status' => FollowStatus::Accepted]); + $blocked->following()->attach($blocker, ['status' => FollowStatus::Accepted]); + + Sanctum::actingAs($blocker); + + $this->postJson("/api/users/{$blocked->id}/block") + ->assertOk() + ->assertJsonPath('message', __('api.blocks.blocked')); + + $this->postJson("/api/users/{$blocked->id}/block")->assertOk(); + + expect(UserBlock::query()->count())->toBe(1) + ->and($blocker->following()->count())->toBe(0) + ->and($blocker->followers()->count())->toBe(0); + + $this->getJson('/api/me/blocked-users') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $blocked->id); + + $this->deleteJson("/api/users/{$blocked->id}/block")->assertNoContent(); + + expect(UserBlock::query()->count())->toBe(0); +}); + +it('does not allow a user to block themselves', function () { + $user = User::factory()->create(); + + Sanctum::actingAs($user); + + $this->postJson("/api/users/{$user->id}/block") + ->assertUnprocessable() + ->assertJsonPath('message', __('api.blocks.cannot_block_self')); +}); + +it('requires authentication to manage blocks', function () { + $user = User::factory()->create(); + + $this->getJson('/api/me/blocked-users')->assertUnauthorized(); + $this->postJson("/api/users/{$user->id}/block")->assertUnauthorized(); + $this->deleteJson("/api/users/{$user->id}/block")->assertUnauthorized(); +}); + +it('hides blocked users and prevents reciprocal content interactions', function () { + $viewer = User::factory()->create(); + $blocked = User::factory()->create(); + $neutral = User::factory()->create(); + $blockedMeal = MealPosts::factory()->for($blocked, 'user')->create(); + $neutralMeal = MealPosts::factory()->for($neutral, 'user')->create(); + $blockedReview = PostReviews::query()->create([ + 'meal_post_id' => $neutralMeal->id, + 'user_id' => $blocked->id, + 'rating' => 4, + 'comment' => 'Avis masqué.', + ]); + + UserBlock::factory()->create([ + 'blocker_id' => $viewer->id, + 'blocked_id' => $blocked->id, + ]); + + Sanctum::actingAs($viewer); + + $this->getJson('/api/meal-posts') + ->assertOk() + ->assertJsonFragment(['id' => $neutralMeal->id]) + ->assertJsonMissing(['id' => $blockedMeal->id]); + + $this->getJson("/api/users/{$blocked->id}")->assertNotFound(); + $this->getJson("/api/meal-posts/{$blockedMeal->id}")->assertNotFound(); + $this->postJson("/api/users/{$blocked->id}/follow")->assertNotFound(); + $this->postJson("/api/meal-posts/{$blockedMeal->id}/like")->assertNotFound(); + $this->postJson("/api/meal-posts/{$blockedMeal->id}/reviews", [ + 'rating' => 5, + 'comment' => 'Interaction interdite.', + ])->assertNotFound(); + + $this->getJson("/api/meal-posts/{$neutralMeal->id}/reviews") + ->assertOk() + ->assertJsonMissing(['id' => $blockedReview->id]); +}); + +it('applies reciprocal exclusion when the other user initiated the block', function () { + $viewer = User::factory()->create(); + $blocker = User::factory()->create(); + $meal = MealPosts::factory()->for($blocker, 'user')->create(); + + UserBlock::factory()->create([ + 'blocker_id' => $blocker->id, + 'blocked_id' => $viewer->id, + ]); + + Sanctum::actingAs($viewer); + + $this->getJson('/api/meal-posts') + ->assertOk() + ->assertJsonMissing(['id' => $meal->id]); + $this->getJson("/api/users/{$blocker->id}")->assertNotFound(); + $this->getJson("/api/meal-posts/{$meal->id}")->assertNotFound(); + $this->postJson("/api/users/{$blocker->id}/follow")->assertNotFound(); +}); + +it('keeps content hidden until both directional blocks are removed', function () { + $firstUser = User::factory()->create(); + $secondUser = User::factory()->create(); + $meal = MealPosts::factory()->for($secondUser, 'user')->create(); + + UserBlock::factory()->create([ + 'blocker_id' => $firstUser->id, + 'blocked_id' => $secondUser->id, + ]); + UserBlock::factory()->create([ + 'blocker_id' => $secondUser->id, + 'blocked_id' => $firstUser->id, + ]); + + Sanctum::actingAs($firstUser); + + $this->deleteJson("/api/users/{$secondUser->id}/block")->assertNoContent(); + $this->getJson("/api/meal-posts/{$meal->id}")->assertNotFound(); + + UserBlock::query() + ->where('blocker_id', $secondUser->id) + ->where('blocked_id', $firstUser->id) + ->delete(); + + $this->getJson("/api/meal-posts/{$meal->id}")->assertOk(); +});