Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/Actions/BlockUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\BlockedUser;
use App\Models\User;

final class BlockUser
{
public function handle(User $user, User $toBlock): void
{
BlockedUser::query()->firstOrCreate([
'user_id' => $user->id,
'blocked_user_id' => $toBlock->id,
]);
}
}
22 changes: 22 additions & 0 deletions app/Actions/UnBlockUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\BlockedUser;
use App\Models\User;

final class UnBlockUser
{
public function handle(User $user, User $toBlock): void
{

BlockedUser::query()
->where([
'user_id' => $user->id,
'blocked_user_id' => $toBlock->id,
])
->delete();
}
}
36 changes: 36 additions & 0 deletions app/Http/Controllers/BlockController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\BlockUser;
use App\Actions\UnBlockUser;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Response;

final class BlockController
{
public function store(
#[CurrentUser] User $loggedInUser,
User $user,
BlockUser $action
): Response {
$action->handle($loggedInUser, $user);

return response(status: 201);

}

public function destroy(
#[CurrentUser] User $loggedInUser,
User $user,
UnBlockUser $action
): Response {
$action->handle($loggedInUser, $user);

return response(status: 204);

}
}
6 changes: 4 additions & 2 deletions app/Http/Controllers/ForYouFeedController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace App\Http\Controllers;

use App\Models\User;
use App\Queries\ForYouFeedQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

Expand All @@ -13,9 +15,9 @@ final class ForYouFeedController
/**
* Get the for-you feed.
*/
public function __invoke(Request $request): JsonResponse
public function __invoke(Request $request, #[CurrentUser] ?User $user): JsonResponse
{
$forYouFeed = new ForYouFeedQuery();
$forYouFeed = new ForYouFeedQuery($user);

$posts = $forYouFeed->builder()
->paginate(
Expand Down
28 changes: 28 additions & 0 deletions app/Models/BlockedUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\BlockedUserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

final class BlockedUser extends Model
{
/** @use HasFactory<BlockedUserFactory> */
use HasFactory;

/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

/** @return BelongsTo<User, $this> */
public function blockedUser(): BelongsTo
{
return $this->belongsTo(User::class, 'blocked_user_id');
}
}
Comment on lines +12 to +28
Copy link

@coderabbitai coderabbitai bot Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add $fillable to prevent MassAssignmentException.

The model lacks $fillable or $guarded protection. The BlockUser action uses firstOrCreate() with user_id and blocked_user_id, which requires these fields to be mass-assignable. Without this configuration, the action will throw a MassAssignmentException at runtime.

Apply this diff:

 final class BlockedUser extends Model
 {
     /** @use HasFactory<BlockedUserFactory> */
     use HasFactory;
 
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'user_id',
+        'blocked_user_id',
+    ];
+
     /** @return BelongsTo<User, $this> */
     public function user(): BelongsTo
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final class BlockedUser extends Model
{
/** @use HasFactory<BlockedUserFactory> */
use HasFactory;
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<User, $this> */
public function blockedUser(): BelongsTo
{
return $this->belongsTo(User::class, 'blocked_user_id');
}
}
final class BlockedUser extends Model
{
/** @use HasFactory<BlockedUserFactory> */
use HasFactory;
/**
* @var list<string>
*/
protected $fillable = [
'user_id',
'blocked_user_id',
];
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<User, $this> */
public function blockedUser(): BelongsTo
{
return $this->belongsTo(User::class, 'blocked_user_id');
}
}
🤖 Prompt for AI Agents
In app/Models/BlockedUser.php around lines 12 to 28, the model lacks
mass-assignment protection so firstOrCreate() will throw
MassAssignmentException; add a protected $fillable property (e.g. ['user_id',
'blocked_user_id']) or alternatively set protected $guarded = [] to allow these
attributes to be mass-assignable; place the property near the top of the class
(before the relations) and ensure it is declared protected.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Model::unguard(); already at AppServiceProvider.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if Model::unguard() is called in AppServiceProvider
rg -n "Model::unguard\(\)" app/Providers/AppServiceProvider.php

Length of output: 28


@hasansoyalan, you're absolutely right! I've confirmed that Model::unguard() is called in AppServiceProvider.php (line 19), which disables mass assignment protection globally. This means the $fillable property is unnecessary for the BlockedUser model.

I apologize for the false positive—the model works correctly as-is with your project's configuration. Thank you for the clarification! 🐰


✏️ Learnings added
Learnt from: hasansoyalan
PR: supo-dev/api#10
File: app/Models/BlockedUser.php:12-28
Timestamp: 2025-10-03T17:22:21.246Z
Learning: In the supo-dev/api repository, Model::unguard() is called globally in AppServiceProvider.php, so models do not require $fillable or $guarded properties for mass assignment protection.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

16 changes: 16 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,20 @@ public function following(): BelongsToMany
{
return $this->belongsToMany(self::class, 'followers', 'follower_id', 'user_id');
}

/**
* @return HasMany<BlockedUser, $this>
*/
public function blockedUsers(): HasMany
{
return $this->hasMany(BlockedUser::class, 'user_id');
}

/**
* @return HasMany<BlockedUser, $this>
*/
public function blockedByUsers(): HasMany
{
return $this->hasMany(BlockedUser::class, 'blocked_user_id');
}
}
5 changes: 5 additions & 0 deletions app/Queries/FollowingFeedQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;

final readonly class FollowingFeedQuery
{
Expand All @@ -23,7 +24,11 @@ public function builder(): Builder

return Post::query()
->whereIn('user_id', $followingUsersQuery)
->whereNotIn('user_id', fn (QueryBuilder $query): QueryBuilder => $query->select('blocked_user_id')
->from('blocked_users')
->where('user_id', $this->user->id))
->with(['user', 'likes'])

->latest('updated_at');
}
}
10 changes: 10 additions & 0 deletions app/Queries/ForYouFeedQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,27 @@
namespace App\Queries;

use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;

final readonly class ForYouFeedQuery
{
public function __construct(
private ?User $user
) {}

/**
* @return Builder<Post>
*/
public function builder(): Builder
{
return Post::query()
->with(['user', 'likes'])
->when($this->user, fn (Builder $query): Builder => $query->whereNotIn('user_id', fn (QueryBuilder $query): QueryBuilder => $query->select('blocked_user_id')
->from('blocked_users')
->where('user_id', $this->user?->id)))

->latest('updated_at');
}
}
28 changes: 28 additions & 0 deletions database/factories/BlockedUserFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\BlockedUser;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;

/**
* @extends Factory<BlockedUser>
*/
final class BlockedUserFactory extends Factory
{
protected $model = BlockedUser::class;

public function definition(): array
{
return [
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
'user_id' => User::factory(),
'blocked_user_id' => User::factory(),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

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('blocked_users', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('blocked_user_id')->constrained('users');
$table->unique(['user_id', 'blocked_user_id']);
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('blocked_users');
}
};
4 changes: 4 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
Route::post('/follows/{user}', [FollowController::class, 'store'])->name('follows.store');
Route::delete('/follows/{user}', [FollowController::class, 'destroy'])->name('follows.destroy');

// Blocks
Route::post('/blocks/{user}', [App\Http\Controllers\BlockController::class, 'store'])->name('blocks.store');
Route::delete('/blocks/{user}', [App\Http\Controllers\BlockController::class, 'destroy'])->name('blocks.destroy');

// Likes...
Route::post('/likes/{post}', [LikeController::class, 'store'])->name('likes.store');
Route::delete('/likes/{post}', [LikeController::class, 'destroy'])->name('likes.destroy');
Expand Down
55 changes: 55 additions & 0 deletions tests/Feature/Http/BlockControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

use App\Models\User;
use Laravel\Sanctum\Sanctum;

it('may block a user', function () {
$user = User::factory()->create();
$targetUser = User::factory()->create();

Sanctum::actingAs($user, ['*']);

$response = $this->postJson(route('blocks.store', $targetUser));

$response->assertStatus(201);

expect($user->blockedUsers()->count())->toBe(1);
});

it('may unblock a user', function () {
$user = User::factory()->create();
$targetUser = User::factory()->create();

App\Models\BlockedUser::factory()->create([
'user_id' => $user->id,
'blocked_user_id' => $targetUser->id,
]);
expect($user->blockedUsers()->count())->toBe(1);

Sanctum::actingAs($user, ['*']);

$response = $this->deleteJson(route('blocks.destroy', $targetUser));

$response->assertStatus(204);

expect($user->refresh()->blockedUsers()->count())->toBe(0);
});

it('cannot block a user twice', function () {
$user = User::factory()->create();
$targetUser = User::factory()->create();

App\Models\BlockedUser::factory()->create([
'user_id' => $user->id,
'blocked_user_id' => $targetUser->id,
]);
Sanctum::actingAs($user, ['*']);

$response = $this->postJson(route('blocks.store', $targetUser));

$response->assertStatus(201);

expect($user->blockedUsers()->count())->toBe(1);
});
42 changes: 42 additions & 0 deletions tests/Unit/Actions/BlockUserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

use App\Models\User;

it('may block a user', function (): void {
$user = User::factory()->create();
$targetUser = User::factory()->create();
$action = app(App\Actions\BlockUser::class);

$action->handle($user, $targetUser);

expect($user->blockedUsers()->count())->toBe(1)
->and($targetUser->blockedByUsers()->count())->toBe(1);
});

test('re-block a user does not duplicate the block', function (): void {
$user = User::factory()->create();
$targetUser = User::factory()->create();
$action = app(App\Actions\BlockUser::class);

$action->handle($user, $targetUser);

expect($user->blockedUsers()->count())->toBe(1)
->and($targetUser->blockedByUsers()->count())->toBe(1);

$action->handle($user, $targetUser);

expect($user->blockedUsers()->count())->toBe(1)
->and($targetUser->blockedByUsers()->count())->toBe(1);
});

test('block users models returns correct data', function (): void {
$user = User::factory()->create();
$targetUser = User::factory()->create();
$action = app(App\Actions\BlockUser::class);
$action->handle($user, $targetUser);
$blockedUser = App\Models\BlockedUser::first();
expect($blockedUser->user->id)->toEqual($user->id);
expect($blockedUser->blockedUser->id)->toEqual($targetUser->id);
});
32 changes: 32 additions & 0 deletions tests/Unit/Actions/UnBlockUserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

use App\Models\User;

it('may unblock a user', function (): void {
$user = User::factory()->create();
$targetUser = User::factory()->create();
app(App\Actions\BlockUser::class)->handle($user, $targetUser);
$action = app(App\Actions\UnBlockUser::class);

expect($user->blockedUsers()->count())->toBe(1)
->and($targetUser->blockedByUsers()->count())->toBe(1);
$action->handle($user, $targetUser);

expect($user->blockedUsers()->count())->toBe(0)
->and($targetUser->blockedByUsers()->count())->toBe(0);

});

test('re-unblock does nothing', function (): void {
$user = User::factory()->create();
$targetUser = User::factory()->create();
$action = app(App\Actions\UnBlockUser::class);

expect($user->blockedUsers()->count())->toBe(0);

$action->handle($user, $targetUser);

expect($user->blockedUsers()->count())->toBe(0);
});
Loading