feat(models): Tenant, User, Role, ParentChild pivot + factories with withRole/withLicense

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Boban Blaskovic 2026-05-22 00:54:07 +02:00
parent a198cd1fbe
commit 1709f0f171
10 changed files with 254 additions and 38 deletions

31
app/Models/License.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use App\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class License extends Model
{
use HasFactory, HasUuid, BelongsToTenant, SoftDeletes;
protected $fillable = ['type', 'seats', 'expires_at', 'active', 'tenant_id'];
protected $casts = [
'active' => 'boolean',
'expires_at' => 'date',
];
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
public function assignments()
{
return $this->hasMany(LicenseAssignment::class);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseAssignment extends Model
{
protected $fillable = ['license_id', 'user_id'];
public function license()
{
return $this->belongsTo(License::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\Traits\HasUuid;
use Illuminate\Database\Eloquent\Relations\Pivot;
class ParentChild extends Pivot
{
use HasUuid;
public $incrementing = true;
protected $table = 'parent_child';
}

15
app/Models/Role.php Normal file
View File

@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Role extends Model
{
protected $fillable = ['name'];
public function users()
{
return $this->belongsToMany(User::class)->withTimestamps();
}
}

27
app/Models/Tenant.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use App\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Tenant extends Model
{
use HasFactory, HasUuid, SoftDeletes;
protected $fillable = ['name', 'type', 'active'];
protected $casts = ['active' => 'boolean'];
public function users()
{
return $this->hasMany(User::class);
}
public function licenses()
{
return $this->hasMany(License::class);
}
}

View File

@ -2,31 +2,69 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Traits\BelongsToTenant;
use Database\Factories\UserFactory; use App\Traits\HasUuid;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ use HasFactory, Notifiable, HasUuid, BelongsToTenant, SoftDeletes;
use HasFactory, Notifiable;
/** protected $fillable = ['name', 'email', 'password', 'tenant_id'];
* Get the attributes that should be cast.
* protected $hidden = ['password', 'remember_token', 'id'];
* @return array<string, string>
*/ protected $casts = ['email_verified_at' => 'datetime', 'password' => 'hashed'];
protected function casts(): array
public function role()
{ {
return [ return $this->belongsToMany(Role::class, 'role_user')->withTimestamps();
'email_verified_at' => 'datetime', }
'password' => 'hashed',
]; 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();
});
} }
} }

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class TenantFactory extends Factory
{
public function definition(): array
{
return [
'name' => $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']);
}
}

View File

@ -2,26 +2,16 @@
namespace Database\Factories; 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\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory class UserFactory extends Factory
{ {
/**
* The current password being used by the factory.
*/
protected static ?string $password; protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array public function definition(): array
{ {
return [ return [
@ -29,17 +19,38 @@ class UserFactory extends Factory
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => \Illuminate\Support\Str::random(10),
'tenant_id' => Tenant::factory(),
]; ];
} }
/** public function withRole(string $roleName): static
* Indicate that the model's email address should be unverified. {
*/ 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 public function unverified(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn () => ['email_verified_at' => null]);
'email_verified_at' => null,
]);
} }
} }

View File

@ -0,0 +1,17 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('verhindert Zugriff auf Daten anderer Tenants', function () {
$tenant1 = Tenant::factory()->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);
});

View File

@ -0,0 +1,15 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('TenantScope gibt leeres ResultSet bei null-User zurück (kein silent-leak)', function () {
$tenant = Tenant::factory()->create();
User::factory()->for($tenant)->count(3)->create();
auth()->logout();
expect(User::count())->toBe(0);
});