From 1709f0f17162451abbcc5d1d3c22924f7bbc8a4a Mon Sep 17 00:00:00 2001 From: Boban Blaskovic Date: Fri, 22 May 2026 00:54:07 +0200 Subject: [PATCH] feat(models): Tenant, User, Role, ParentChild pivot + factories with withRole/withLicense Co-Authored-By: Claude Sonnet 4.6 --- app/Models/License.php | 31 ++++++++ app/Models/LicenseAssignment.php | 20 +++++ app/Models/ParentChild.php | 15 ++++ app/Models/Role.php | 15 ++++ app/Models/Tenant.php | 27 +++++++ app/Models/User.php | 74 ++++++++++++++----- database/factories/TenantFactory.php | 27 +++++++ database/factories/UserFactory.php | 51 ++++++++----- tests/Feature/Tenant/TenantIsolationTest.php | 17 +++++ .../Tenant/TenantScopeNullUserTest.php | 15 ++++ 10 files changed, 254 insertions(+), 38 deletions(-) create mode 100644 app/Models/License.php create mode 100644 app/Models/LicenseAssignment.php create mode 100644 app/Models/ParentChild.php create mode 100644 app/Models/Role.php create mode 100644 app/Models/Tenant.php create mode 100644 database/factories/TenantFactory.php create mode 100644 tests/Feature/Tenant/TenantIsolationTest.php create mode 100644 tests/Feature/Tenant/TenantScopeNullUserTest.php diff --git a/app/Models/License.php b/app/Models/License.php new file mode 100644 index 0000000..8ade2c4 --- /dev/null +++ b/app/Models/License.php @@ -0,0 +1,31 @@ + 'boolean', + 'expires_at' => 'date', + ]; + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + + public function assignments() + { + return $this->hasMany(LicenseAssignment::class); + } +} diff --git a/app/Models/LicenseAssignment.php b/app/Models/LicenseAssignment.php new file mode 100644 index 0000000..c2f834e --- /dev/null +++ b/app/Models/LicenseAssignment.php @@ -0,0 +1,20 @@ +belongsTo(License::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ParentChild.php b/app/Models/ParentChild.php new file mode 100644 index 0000000..9c49322 --- /dev/null +++ b/app/Models/ParentChild.php @@ -0,0 +1,15 @@ +belongsToMany(User::class)->withTimestamps(); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php new file mode 100644 index 0000000..0ef66fd --- /dev/null +++ b/app/Models/Tenant.php @@ -0,0 +1,27 @@ + 'boolean']; + + public function users() + { + return $this->hasMany(User::class); + } + + public function licenses() + { + return $this->hasMany(License::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..ff47358 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,31 +2,69 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; -use Database\Factories\UserFactory; -use Illuminate\Database\Eloquent\Attributes\Fillable; -use Illuminate\Database\Eloquent\Attributes\Hidden; +use App\Traits\BelongsToTenant; +use App\Traits\HasUuid; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -#[Fillable(['name', 'email', 'password'])] -#[Hidden(['password', 'remember_token'])] class User extends Authenticatable { - /** @use HasFactory */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasUuid, BelongsToTenant, SoftDeletes; - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array + protected $fillable = ['name', 'email', 'password', 'tenant_id']; + + protected $hidden = ['password', 'remember_token', 'id']; + + protected $casts = ['email_verified_at' => 'datetime', 'password' => 'hashed']; + + public function role() { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; + return $this->belongsToMany(Role::class, 'role_user')->withTimestamps(); + } + + public function hasRole(string $role): bool + { + return $this->role()->where('roles.name', $role)->exists(); + } + + public function tenant() + { + return $this->belongsTo(Tenant::class); + } + + public function licenseAssignments() + { + return $this->hasMany(LicenseAssignment::class); + } + + public function children() + { + return $this->belongsToMany(User::class, 'parent_child', 'parent_id', 'child_id') + ->using(ParentChild::class) + ->withTimestamps() + ->withoutGlobalScope(\App\Scopes\TenantScope::class); + } + + public function parents() + { + return $this->belongsToMany(User::class, 'parent_child', 'child_id', 'parent_id') + ->using(ParentChild::class) + ->withTimestamps() + ->withoutGlobalScope(\App\Scopes\TenantScope::class); + } + + protected static function booted(): void + { + static::deleting(function (User $user) { + // Soft-delete cascade: DB foreign key cascades don't fire on soft-delete + \DB::table('role_user')->where('user_id', $user->id)->delete(); + \DB::table('license_assignments')->where('user_id', $user->id)->delete(); + \DB::table('parent_child') + ->where('parent_id', $user->id) + ->orWhere('child_id', $user->id) + ->delete(); + }); } } diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php new file mode 100644 index 0000000..7d4e45e --- /dev/null +++ b/database/factories/TenantFactory.php @@ -0,0 +1,27 @@ + $this->faker->company(), + 'type' => 'school', + 'active' => true, + ]; + } + + public function private(): static + { + return $this->state(['type' => 'private']); + } + + public function system(): static + { + return $this->state(['type' => 'system', 'name' => 'System']); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index c4ceb07..9450014 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,26 +2,16 @@ namespace Database\Factories; -use App\Models\User; +use App\Models\License; +use App\Models\Role; +use App\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Str; -/** - * @extends Factory - */ class UserFactory extends Factory { - /** - * The current password being used by the factory. - */ protected static ?string $password; - /** - * Define the model's default state. - * - * @return array - */ public function definition(): array { return [ @@ -29,17 +19,38 @@ class UserFactory extends Factory 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), + 'remember_token' => \Illuminate\Support\Str::random(10), + 'tenant_id' => Tenant::factory(), ]; } - /** - * Indicate that the model's email address should be unverified. - */ + public function withRole(string $roleName): static + { + return $this->afterCreating(function ($user) use ($roleName) { + $role = Role::firstOrCreate(['name' => $roleName]); + \DB::table('role_user')->insertOrIgnore([ + 'role_id' => $role->id, + 'user_id' => $user->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + }); + } + + public function withLicense(License $license): static + { + return $this->afterCreating(function ($user) use ($license) { + \DB::table('license_assignments')->insertOrIgnore([ + 'license_id' => $license->id, + 'user_id' => $user->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + }); + } + public function unverified(): static { - return $this->state(fn (array $attributes) => [ - 'email_verified_at' => null, - ]); + return $this->state(fn () => ['email_verified_at' => null]); } } diff --git a/tests/Feature/Tenant/TenantIsolationTest.php b/tests/Feature/Tenant/TenantIsolationTest.php new file mode 100644 index 0000000..a219c7b --- /dev/null +++ b/tests/Feature/Tenant/TenantIsolationTest.php @@ -0,0 +1,17 @@ +create(); + $tenant2 = Tenant::factory()->create(); + $user1 = User::factory()->for($tenant1)->create(); + $user2 = User::factory()->for($tenant2)->create(); + + $this->actingAs($user1); + expect(User::all()->pluck('id'))->not->toContain($user2->id); +}); diff --git a/tests/Feature/Tenant/TenantScopeNullUserTest.php b/tests/Feature/Tenant/TenantScopeNullUserTest.php new file mode 100644 index 0000000..b07ef34 --- /dev/null +++ b/tests/Feature/Tenant/TenantScopeNullUserTest.php @@ -0,0 +1,15 @@ +create(); + User::factory()->for($tenant)->count(3)->create(); + + auth()->logout(); + expect(User::count())->toBe(0); +});