feat: add reciprocal user blocking
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserBlock;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class BlockUser
|
||||||
|
{
|
||||||
|
public function execute(User $blocker, User $blocked): UserBlock
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($blocker, $blocked): UserBlock {
|
||||||
|
$userBlock = UserBlock::query()->firstOrCreate([
|
||||||
|
'blocker_id' => $blocker->getKey(),
|
||||||
|
'blocked_id' => $blocked->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$blocker->following()->detach($blocked->getKey());
|
||||||
|
$blocker->followers()->detach($blocked->getKey());
|
||||||
|
|
||||||
|
return $userBlock;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,12 @@ class FollowController extends Controller
|
|||||||
|
|
||||||
if ($authUser->id === $user->id) {
|
if ($authUser->id === $user->id) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => __('api.follows.cannot_follow_self')
|
'message' => __('api.follows.cannot_follow_self'),
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_if($authUser->hasBlockingRelationshipWith($user), 404);
|
||||||
|
|
||||||
// Default to Accepted as is_private doesn't exist in the DB yet
|
// Default to Accepted as is_private doesn't exist in the DB yet
|
||||||
$status = FollowStatus::Accepted;
|
$status = FollowStatus::Accepted;
|
||||||
|
|
||||||
@@ -44,6 +46,8 @@ class FollowController extends Controller
|
|||||||
|
|
||||||
public function accept(FollowRequest $request, User $user): JsonResponse
|
public function accept(FollowRequest $request, User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
abort_if($request->user()->hasBlockingRelationshipWith($user), 404);
|
||||||
|
|
||||||
$updated = $request->user()->followers()->updateExistingPivot($user->id, [
|
$updated = $request->user()->followers()->updateExistingPivot($user->id, [
|
||||||
'status' => FollowStatus::Accepted,
|
'status' => FollowStatus::Accepted,
|
||||||
]);
|
]);
|
||||||
@@ -55,6 +59,8 @@ class FollowController extends Controller
|
|||||||
|
|
||||||
public function reject(FollowRequest $request, User $user): JsonResponse
|
public function reject(FollowRequest $request, User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
abort_if($request->user()->hasBlockingRelationshipWith($user), 404);
|
||||||
|
|
||||||
$detached = $request->user()->followers()->detach($user->id);
|
$detached = $request->user()->followers()->detach($user->id);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -70,6 +76,7 @@ class FollowController extends Controller
|
|||||||
|
|
||||||
$followers = $user->followers()
|
$followers = $user->followers()
|
||||||
->wherePivot('status', FollowStatus::Accepted)
|
->wherePivot('status', FollowStatus::Accepted)
|
||||||
|
->withoutBlockingRelationshipWith($request->user())
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
return FollowResource::collection($followers);
|
return FollowResource::collection($followers);
|
||||||
@@ -83,6 +90,7 @@ class FollowController extends Controller
|
|||||||
|
|
||||||
$following = $user->following()
|
$following = $user->following()
|
||||||
->wherePivot('status', FollowStatus::Accepted)
|
->wherePivot('status', FollowStatus::Accepted)
|
||||||
|
->withoutBlockingRelationshipWith($request->user())
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
return FollowResource::collection($following);
|
return FollowResource::collection($following);
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class MealPostController extends Controller
|
|||||||
|
|
||||||
public function show(Request $request, MealPosts $mealPost): MealPostsResource
|
public function show(Request $request, MealPosts $mealPost): MealPostsResource
|
||||||
{
|
{
|
||||||
$this->abortIfUnavailable($mealPost);
|
$this->abortIfUnavailable($request, $mealPost);
|
||||||
|
|
||||||
return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request));
|
return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request));
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ class MealPostController extends Controller
|
|||||||
|
|
||||||
public function like(Request $request, MealPosts $mealPost): MealPostsResource
|
public function like(Request $request, MealPosts $mealPost): MealPostsResource
|
||||||
{
|
{
|
||||||
$this->abortIfUnavailable($mealPost);
|
$this->abortIfUnavailable($request, $mealPost);
|
||||||
|
|
||||||
$mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]);
|
$mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]);
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ class MealPostController extends Controller
|
|||||||
|
|
||||||
public function unlike(Request $request, MealPosts $mealPost): MealPostsResource
|
public function unlike(Request $request, MealPosts $mealPost): MealPostsResource
|
||||||
{
|
{
|
||||||
$this->abortIfUnavailable($mealPost);
|
$this->abortIfUnavailable($request, $mealPost);
|
||||||
|
|
||||||
$mealPost->likedByUsers()->detach($request->user()->getKey());
|
$mealPost->likedByUsers()->detach($request->user()->getKey());
|
||||||
|
|
||||||
@@ -204,10 +204,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 abortIfUnavailable(MealPosts $mealPost): void
|
private function abortIfUnavailable(Request $request, MealPosts $mealPost): void
|
||||||
{
|
{
|
||||||
|
$mealPost->loadMissing('user');
|
||||||
|
$owner = $mealPost->user;
|
||||||
|
|
||||||
abort_if(
|
abort_if(
|
||||||
$mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(),
|
$mealPost->isHidden()
|
||||||
|
|| $owner === null
|
||||||
|
|| $owner->isSuspended()
|
||||||
|
|| $request->user()?->hasBlockingRelationshipWith($owner),
|
||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -222,7 +228,12 @@ class MealPostController extends Controller
|
|||||||
{
|
{
|
||||||
return MealPosts::query()
|
return MealPosts::query()
|
||||||
->whereNull('hidden_at')
|
->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');
|
->with('user:id,name,avatar_url,account_verified_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Resources\PostReviewsResource;
|
|||||||
use App\Models\MealPosts;
|
use App\Models\MealPosts;
|
||||||
use App\Models\PostReviews;
|
use App\Models\PostReviews;
|
||||||
use App\Notifications\MealPostCommentedNotification;
|
use App\Notifications\MealPostCommentedNotification;
|
||||||
|
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;
|
||||||
@@ -16,11 +17,15 @@ class PostReviewsController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request, MealPosts $mealPost): AnonymousResourceCollection
|
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));
|
$perPage = max(1, min($request->integer('per_page', 15), 100));
|
||||||
|
|
||||||
$reviews = $mealPost->reviews()
|
$reviews = $mealPost->reviews()
|
||||||
|
->whereHas(
|
||||||
|
'user',
|
||||||
|
fn (Builder $query): Builder => $query->withoutBlockingRelationshipWith($request->user())
|
||||||
|
)
|
||||||
->with('user:id,name,avatar_url')
|
->with('user:id,name,avatar_url')
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage)
|
->paginate($perPage)
|
||||||
@@ -31,7 +36,7 @@ class PostReviewsController extends Controller
|
|||||||
|
|
||||||
public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse
|
public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse
|
||||||
{
|
{
|
||||||
$this->abortIfUnavailable($mealPost);
|
$this->abortIfUnavailable($request, $mealPost);
|
||||||
|
|
||||||
$userId = $request->user()->getKey();
|
$userId = $request->user()->getKey();
|
||||||
|
|
||||||
@@ -60,7 +65,7 @@ class PostReviewsController extends Controller
|
|||||||
|
|
||||||
public function mine(Request $request, MealPosts $mealPost): JsonResponse|PostReviewsResource
|
public function mine(Request $request, MealPosts $mealPost): JsonResponse|PostReviewsResource
|
||||||
{
|
{
|
||||||
$this->abortIfUnavailable($mealPost);
|
$this->abortIfUnavailable($request, $mealPost);
|
||||||
|
|
||||||
$postReview = $mealPost->reviews()
|
$postReview = $mealPost->reviews()
|
||||||
->where('user_id', $request->user()->getKey())
|
->where('user_id', $request->user()->getKey())
|
||||||
@@ -74,10 +79,15 @@ class PostReviewsController extends Controller
|
|||||||
return new PostReviewsResource($postReview);
|
return new PostReviewsResource($postReview);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(PostReviews $postReview): PostReviewsResource
|
public function show(Request $request, PostReviews $postReview): PostReviewsResource
|
||||||
{
|
{
|
||||||
$postReview->loadMissing('mealPost.user');
|
$postReview->loadMissing('mealPost.user', 'user');
|
||||||
$this->abortIfUnavailable($postReview->mealPost);
|
$this->abortIfUnavailable($request, $postReview->mealPost);
|
||||||
|
abort_if(
|
||||||
|
$postReview->user === null
|
||||||
|
|| $request->user()->hasBlockingRelationshipWith($postReview->user),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
|
||||||
return new PostReviewsResource($postReview->loadMissing(
|
return new PostReviewsResource($postReview->loadMissing(
|
||||||
'mealPost.ingredients',
|
'mealPost.ingredients',
|
||||||
@@ -110,10 +120,16 @@ class PostReviewsController extends Controller
|
|||||||
abort_unless($postReview->user_id === $request->user()->getKey(), 404);
|
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(
|
abort_if(
|
||||||
$mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(),
|
$mealPost->isHidden()
|
||||||
|
|| $owner === null
|
||||||
|
|| $owner->isSuspended()
|
||||||
|
|| $request->user()?->hasBlockingRelationshipWith($owner),
|
||||||
404,
|
404,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class PublicUserProfileController extends Controller
|
|||||||
public function show(Request $request, User $user): JsonResponse
|
public function show(Request $request, User $user): JsonResponse
|
||||||
{
|
{
|
||||||
abort_if($user->isSuspended(), 404);
|
abort_if($user->isSuspended(), 404);
|
||||||
|
abort_if($request->user()?->hasBlockingRelationshipWith($user), 404);
|
||||||
|
|
||||||
$meals = $user->mealPosts()
|
$meals = $user->mealPosts()
|
||||||
->where('visibility', MealPostVisibility::Public->value)
|
->where('visibility', MealPostVisibility::Public->value)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\BlockUser;
|
||||||
|
use App\Http\Resources\BlockedUserResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserBlock;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class UserBlockController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$perPage = max(1, min($request->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/** @mixin User */
|
||||||
|
class BlockedUserResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-2
@@ -12,9 +12,11 @@ use Filament\Models\Contracts\HasAvatar;
|
|||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Contracts\Translation\HasLocalePreference;
|
use Illuminate\Contracts\Translation\HasLocalePreference;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
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\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\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
@@ -152,20 +154,45 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
|||||||
return $this->morphMany(Report::class, 'reportable');
|
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')
|
return $this->belongsToMany(self::class, 'follows', 'following_id', 'follower_id')
|
||||||
->withPivot('status')
|
->withPivot('status')
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function following(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
public function following(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(self::class, 'follows', 'follower_id', 'following_id')
|
return $this->belongsToMany(self::class, 'follows', 'follower_id', 'following_id')
|
||||||
->withPivot('status')
|
->withPivot('status')
|
||||||
->withTimestamps();
|
->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
|
public function moderationCase(): MorphOne
|
||||||
{
|
{
|
||||||
return $this->morphOne(ModerationCase::class, 'caseable');
|
return $this->morphOne(ModerationCase::class, 'caseable');
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserBlock extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserBlockFactory> */
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserBlock>
|
||||||
|
*/
|
||||||
|
class UserBlockFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'blocker_id' => User::factory(),
|
||||||
|
'blocked_id' => User::factory(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_blocks', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -106,6 +106,10 @@ return [
|
|||||||
'rejected' => 'Follow request rejected.',
|
'rejected' => 'Follow request rejected.',
|
||||||
'not_found' => 'No request found.',
|
'not_found' => 'No request found.',
|
||||||
],
|
],
|
||||||
|
'blocks' => [
|
||||||
|
'blocked' => 'User blocked.',
|
||||||
|
'cannot_block_self' => 'You cannot block yourself.',
|
||||||
|
],
|
||||||
'validation' => [
|
'validation' => [
|
||||||
'image_required' => 'Add an image to analyze.',
|
'image_required' => 'Add an image to analyze.',
|
||||||
'image_file' => 'The file must be an image.',
|
'image_file' => 'The file must be an image.',
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ return [
|
|||||||
'rejected' => 'Demande d’abonnement refusée.',
|
'rejected' => 'Demande d’abonnement refusée.',
|
||||||
'not_found' => 'Aucune demande trouvée.',
|
'not_found' => 'Aucune demande trouvée.',
|
||||||
],
|
],
|
||||||
|
'blocks' => [
|
||||||
|
'blocked' => 'Utilisateur bloqué.',
|
||||||
|
'cannot_block_self' => 'Vous ne pouvez pas vous bloquer vous-même.',
|
||||||
|
],
|
||||||
'validation' => [
|
'validation' => [
|
||||||
'image_required' => 'Ajoute une image à analyser.',
|
'image_required' => 'Ajoute une image à analyser.',
|
||||||
'image_file' => 'Le fichier doit être une image.',
|
'image_file' => 'Le fichier doit être une image.',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Http\Controllers\PostReviewsController;
|
|||||||
use App\Http\Controllers\PublicUserProfileController;
|
use App\Http\Controllers\PublicUserProfileController;
|
||||||
use App\Http\Controllers\ReportController;
|
use App\Http\Controllers\ReportController;
|
||||||
use App\Http\Controllers\StravaController;
|
use App\Http\Controllers\StravaController;
|
||||||
|
use App\Http\Controllers\UserBlockController;
|
||||||
use App\Http\Controllers\WorkoutSessionController;
|
use App\Http\Controllers\WorkoutSessionController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -90,6 +91,9 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function
|
|||||||
// Meals
|
// Meals
|
||||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||||
Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show');
|
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::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::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');
|
Route::post('users/{user}/accept', [FollowController::class, 'accept'])->middleware('throttle:engagement')->name('users.accept');
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\FollowStatus;
|
||||||
|
use App\Models\MealPosts;
|
||||||
|
use App\Models\PostReviews;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserBlock;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('blocks a user idempotently, removes follows, lists the block, and unblocks', function () {
|
||||||
|
$blocker = User::factory()->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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user