Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill

- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er)
- Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link
- Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest
- Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen)
- Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ActivityLog extends Model
{
public $timestamps = false;
protected $fillable = [
'user_id',
'action',
'model_type',
'model_id',
'description',
'properties',
'created_at',
];
protected function casts(): array
{
return [
'properties' => 'array',
'created_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
public static function log(
string $action,
string $description,
?string $modelType = null,
?int $modelId = null,
?array $properties = null,
): self {
$log = new static();
$log->user_id = auth()->id();
$log->action = $action;
$log->model_type = $modelType;
$log->model_id = $modelId;
$log->description = $description;
$log->properties = $properties;
$log->ip_address = request()->ip();
$log->created_at = now();
$log->save();
return $log;
}
public static function logWithChanges(
string $action,
string $description,
?string $modelType = null,
?int $modelId = null,
?array $old = null,
?array $new = null,
): self {
$properties = null;
if ($old !== null || $new !== null) {
$properties = array_filter(['old' => $old, 'new' => $new], fn ($v) => $v !== null);
}
return static::log($action, $description, $modelType, $modelId, $properties ?: null);
}
}

46
app/Models/Comment.php Executable file
View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
protected $fillable = [
'event_id',
'body',
];
protected function casts(): array
{
return [
'deleted_at' => 'datetime',
];
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
public function deletedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by')->withTrashed();
}
public function isDeleted(): bool
{
return $this->deleted_at !== null;
}
public function scopeVisible($query)
{
return $query->whereNull('deleted_at');
}
}

291
app/Models/Event.php Executable file
View File

@@ -0,0 +1,291 @@
<?php
namespace App\Models;
use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
class Event extends Model
{
use SoftDeletes;
protected $fillable = [
'team_id',
'type',
'title',
'start_at',
'end_at',
'status',
'location_name',
'address_text',
'location_lat',
'location_lng',
'description_html',
'min_players',
'min_catering',
'min_timekeepers',
'opponent',
'score_home',
'score_away',
];
protected function casts(): array
{
return [
'type' => EventType::class,
'status' => EventStatus::class,
'start_at' => 'datetime',
'end_at' => 'datetime',
'location_lat' => 'float',
'location_lng' => 'float',
'min_players' => 'integer',
'min_catering' => 'integer',
'min_timekeepers' => 'integer',
'score_home' => 'integer',
'score_away' => 'integer',
];
}
public function hasCoordinates(): bool
{
return $this->location_lat !== null && $this->location_lng !== null;
}
public function hasScore(): bool
{
return $this->type->isGameType() && ($this->score_home !== null || $this->score_away !== null);
}
public function scoreDisplay(): ?string
{
if (!$this->hasScore()) {
return null;
}
return ($this->score_home ?? '?') . ':' . ($this->score_away ?? '?');
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by')->withTrashed();
}
public function deletedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by')->withTrashed();
}
public function isRestorable(): bool
{
return $this->trashed() && $this->deleted_at->diffInDays(now()) <= 30;
}
public function participants(): HasMany
{
return $this->hasMany(EventParticipant::class);
}
public function caterings(): HasMany
{
return $this->hasMany(EventCatering::class);
}
public function timekeepers(): HasMany
{
return $this->hasMany(EventTimekeeper::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function faqs(): BelongsToMany
{
return $this->belongsToMany(Faq::class, 'event_faq');
}
public function files(): BelongsToMany
{
return $this->belongsToMany(File::class, 'event_file')->withPivot('created_at');
}
/**
* Check if all set minimums are met.
* Returns: true = all met, false = at least one not met, null = no minimums set.
*/
public function minimumsStatus(): ?bool
{
$hasAny = false;
$allMet = true;
if ($this->min_players !== null) {
$hasAny = true;
if ($this->type === EventType::Meeting) {
// Für Besprechungen: User mit Zusage zählen (user_id-basiert)
$count = $this->participants
->where('status', ParticipantStatus::Yes)
->whereNotNull('user_id')
->count();
} else {
$count = $this->participants->where('status', ParticipantStatus::Yes)->count();
}
if ($count < $this->min_players) {
$allMet = false;
}
}
// Catering/Zeitnehmer nur für Typen die es unterstützen
if ($this->type->hasCatering() && $this->min_catering !== null) {
$hasAny = true;
$cateringYes = $this->caterings_yes_count
?? $this->caterings->where('status', CateringStatus::Yes)->count();
if ($cateringYes < $this->min_catering) {
$allMet = false;
}
}
if ($this->type->hasTimekeepers() && $this->min_timekeepers !== null) {
$hasAny = true;
$timekeeperYes = $this->timekeepers_yes_count
?? $this->timekeepers->where('status', CateringStatus::Yes)->count();
if ($timekeeperYes < $this->min_timekeepers) {
$allMet = false;
}
}
return $hasAny ? $allMet : null;
}
/**
* Add missing active team players as participants (idempotent).
* For meetings, delegates to syncMeetingParticipants().
*/
public function syncParticipants(int $userId): void
{
if ($this->type === EventType::Meeting) {
$this->syncMeetingParticipants($userId);
return;
}
$activePlayerIds = $this->team->activePlayers()->pluck('id');
$existingPlayerIds = $this->participants()->pluck('player_id');
$missingPlayerIds = $activePlayerIds->diff($existingPlayerIds);
if ($missingPlayerIds->isEmpty()) {
return;
}
$now = now();
$records = $missingPlayerIds->map(fn ($playerId) => [
'event_id' => $this->id,
'player_id' => $playerId,
'status' => ParticipantStatus::Unknown->value,
'set_by_user_id' => $userId,
'created_at' => $now,
'updated_at' => $now,
])->toArray();
$this->participants()->insert($records);
}
/**
* Sync meeting participants: eligible users for this event's team.
* Eligible = parents with children in team + coaches (excluding admin ID 1).
*/
public function syncMeetingParticipants(int $setByUserId): void
{
$teamId = $this->team_id;
// Users with active children in this team
$parentUserIds = DB::table('parent_player')
->join('players', 'parent_player.player_id', '=', 'players.id')
->where('players.team_id', $teamId)
->whereNull('players.deleted_at')
->where('players.is_active', true)
->join('users', 'parent_player.parent_id', '=', 'users.id')
->whereNull('users.deleted_at')
->where('users.is_active', true)
->pluck('parent_player.parent_id')
->unique();
// Coaches — nur die dem Team zugeordneten
$coachIds = User::where('role', UserRole::Coach->value)
->where('is_active', true)
->whereNull('deleted_at')
->whereHas('coachTeams', fn ($q) => $q->where('teams.id', $teamId))
->pluck('id');
// Admins aus Meeting-Teilnehmern ausschließen (rollenbasiert statt ID-basiert, V12)
$adminIds = User::where('role', UserRole::Admin)->pluck('id');
$eligibleUserIds = $parentUserIds->merge($coachIds)->unique()->diff($adminIds);
$existingUserIds = $this->participants()->whereNotNull('user_id')->pluck('user_id');
$missingUserIds = $eligibleUserIds->diff($existingUserIds);
if ($missingUserIds->isEmpty()) {
return;
}
$now = now();
$records = $missingUserIds->map(fn ($userId) => [
'event_id' => $this->id,
'player_id' => null,
'user_id' => $userId,
'status' => ParticipantStatus::Unknown->value,
'set_by_user_id' => $setByUserId,
'created_at' => $now,
'updated_at' => $now,
])->toArray();
$this->participants()->insert($records);
}
/**
* Sync participants for all future events of a team.
*/
public static function syncParticipantsForTeam(int $teamId, int $userId): void
{
$futureEvents = static::where('team_id', $teamId)
->where('start_at', '>=', now())
->get();
foreach ($futureEvents as $event) {
$event->syncParticipants($userId);
}
}
public function scopePublished($query)
{
return $query->whereIn('status', [EventStatus::Published, EventStatus::Cancelled]);
}
public function scopeUpcoming($query)
{
return $query->where('start_at', '>=', now())->orderBy('start_at');
}
public function scopeForTeam($query, int $teamId)
{
return $query->where('team_id', $teamId);
}
}

35
app/Models/EventCatering.php Executable file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Enums\CateringStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventCatering extends Model
{
protected $table = 'event_catering';
protected $fillable = [
'event_id',
'status',
'note',
];
protected function casts(): array
{
return [
'status' => CateringStatus::class,
];
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
}

56
app/Models/EventParticipant.php Executable file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\ParticipantStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventParticipant extends Model
{
protected $fillable = [
'event_id',
'player_id',
'user_id',
'status',
'note',
'responded_at',
];
protected function casts(): array
{
return [
'status' => ParticipantStatus::class,
'responded_at' => 'datetime',
];
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function player(): BelongsTo
{
return $this->belongsTo(Player::class)->withTrashed();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
public function setByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'set_by_user_id')->withTrashed();
}
public function participantName(): string
{
if ($this->user_id) {
return $this->user?->name ?? __('ui.unknown');
}
return $this->player?->full_name ?? __('ui.unknown');
}
}

26
app/Models/EventTimekeeper.php Executable file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use App\Enums\CateringStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventTimekeeper extends Model
{
protected $fillable = ['event_id', 'status'];
protected $casts = [
'status' => CateringStatus::class,
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
}

34
app/Models/Faq.php Executable file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Faq extends Model
{
protected $table = 'faq';
protected $fillable = [
'title',
'category',
'content_html',
'sort_order',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by')->withTrashed();
}
public function events(): BelongsToMany
{
return $this->belongsToMany(Event::class, 'event_faq');
}
}

115
app/Models/File.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\Storage;
class File extends Model
{
protected $fillable = [
'file_category_id',
'original_name',
'mime_type',
'size',
];
protected function casts(): array
{
return [
'size' => 'integer',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(FileCategory::class, 'file_category_id');
}
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by')->withTrashed();
}
public function events(): BelongsToMany
{
return $this->belongsToMany(Event::class, 'event_file')->withPivot('created_at');
}
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class, 'team_file')->withPivot('created_at');
}
public function getStoragePath(): string
{
return 'files/' . $this->stored_name;
}
public function isImage(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
public function isPdf(): bool
{
return $this->mime_type === 'application/pdf';
}
public function isHtml(): bool
{
return $this->mime_type === 'text/html';
}
public function humanSize(): string
{
$bytes = $this->size;
if ($bytes >= 1048576) {
return round($bytes / 1048576, 1) . ' MB';
}
return round($bytes / 1024) . ' KB';
}
public function iconType(): string
{
return match (true) {
str_contains($this->mime_type, 'pdf') => 'pdf',
str_contains($this->mime_type, 'wordprocessingml') => 'word',
str_contains($this->mime_type, 'spreadsheetml') => 'excel',
$this->isImage() => 'image',
$this->isHtml() => 'html',
default => 'file',
};
}
public function extension(): string
{
return pathinfo($this->original_name, PATHINFO_EXTENSION);
}
public function previewData(): array
{
$hasPreview = $this->isImage() || $this->isPdf();
return [
'name' => $this->original_name,
'category' => $this->category->name ?? '',
'size' => $this->humanSize(),
'downloadUrl' => route('files.download', $this),
'previewUrl' => $hasPreview ? route('files.preview', $this) : null,
'isImage' => $this->isImage(),
'isPdf' => $this->isPdf(),
'isHtml' => $this->isHtml(),
'iconBg' => match ($this->iconType()) {
'pdf' => 'bg-red-100 text-red-600',
'word' => 'bg-blue-100 text-blue-600',
'excel' => 'bg-green-100 text-green-600',
'image' => 'bg-purple-100 text-purple-600',
'html' => 'bg-orange-100 text-orange-600',
default => 'bg-gray-100 text-gray-600',
},
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class FileCategory extends Model
{
protected $fillable = [
'name',
'slug',
'sort_order',
'is_active',
];
protected function casts(): array
{
return [
'sort_order' => 'integer',
'is_active' => 'boolean',
];
}
protected static function booted(): void
{
static::creating(function (FileCategory $category) {
if (empty($category->slug)) {
$category->slug = Str::slug($category->name);
}
});
}
public function files(): HasMany
{
return $this->hasMany(File::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('name');
}
}

52
app/Models/Invitation.php Executable file
View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Invitation extends Model
{
public $timestamps = false;
protected $fillable = [
'email',
'expires_at',
'created_at',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
'created_at' => 'datetime',
];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
public function players(): BelongsToMany
{
return $this->belongsToMany(Player::class, 'invitation_players');
}
public function isValid(): bool
{
return $this->accepted_at === null && $this->expires_at->isFuture();
}
public function isAccepted(): bool
{
return $this->accepted_at !== null;
}
public function scopeValid($query)
{
return $query->whereNull('accepted_at')->where('expires_at', '>', now());
}
}

10
app/Models/Location.php Executable file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Location extends Model
{
protected $fillable = ['name', 'address_text', 'location_lat', 'location_lng'];
}

77
app/Models/Player.php Executable file
View File

@@ -0,0 +1,77 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Player extends Model
{
use SoftDeletes;
protected $fillable = [
'team_id',
'first_name',
'last_name',
'birth_year',
'jersey_number',
'is_active',
'photo_permission',
'notes',
'profile_picture',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'photo_permission' => 'boolean',
];
}
public function getFullNameAttribute(): string
{
return "{$this->first_name} {$this->last_name}";
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function parents(): BelongsToMany
{
return $this->belongsToMany(User::class, 'parent_player', 'player_id', 'parent_id')
->withPivot('relationship_label', 'created_at');
}
public function participations(): HasMany
{
return $this->hasMany(EventParticipant::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function getAvatarUrl(): ?string
{
if ($this->profile_picture) {
return asset('storage/' . $this->profile_picture);
}
return null;
}
public function getInitials(): string
{
return mb_strtoupper(mb_substr($this->first_name, 0, 1) . mb_substr($this->last_name, 0, 1));
}
public function isRestorable(): bool
{
return $this->trashed() && $this->deleted_at->diffInDays(now()) < 7;
}
}

47
app/Models/Setting.php Executable file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class Setting extends Model
{
protected $fillable = ['label', 'type', 'value'];
public static function get(string $key, ?string $default = null): ?string
{
return Cache::remember("setting.{$key}", 3600, function () use ($key, $default) {
return static::where('key', $key)->value('value') ?? $default;
});
}
public static function set(string $key, ?string $value): void
{
static::where('key', $key)->update(['value' => $value]);
Cache::forget("setting.{$key}");
}
public static function clearCache(): void
{
$keys = static::pluck('key');
foreach ($keys as $key) {
Cache::forget("setting.{$key}");
}
}
/**
* Prüft ob ein Feature für den gegebenen User sichtbar ist.
* Admin sieht immer alles.
*/
public static function isFeatureVisibleFor(string $feature, User $user): bool
{
if ($user->isAdmin()) {
return true;
}
$key = "visibility_{$feature}_{$user->role->value}";
return static::get($key, '1') === '1';
}
}

67
app/Models/Team.php Executable file
View File

@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class Team extends Model
{
protected $fillable = [
'name',
'year_group',
'is_active',
'notes',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
public function players(): HasMany
{
return $this->hasMany(Player::class);
}
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
public function activePlayers(): HasMany
{
return $this->hasMany(Player::class)->where('is_active', true);
}
public function coaches(): BelongsToMany
{
return $this->belongsToMany(User::class, 'team_user')
->withPivot('created_at');
}
public function files(): BelongsToMany
{
return $this->belongsToMany(File::class, 'team_file')
->withPivot('created_at');
}
public function parentReps(): Collection
{
return User::where('role', UserRole::ParentRep)
->where('is_active', true)
->whereHas('children', fn ($q) => $q->where('team_id', $this->id))
->orderBy('name')
->get();
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

202
app/Models/User.php Executable file
View File

@@ -0,0 +1,202 @@
<?php
namespace App\Models;
use App\Enums\UserRole;
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\SoftDeletes;
use Illuminate\Notifications\Notifiable;
use App\Notifications\ResetPasswordNotification;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Collection;
class User extends Authenticatable
{
use HasFactory, Notifiable, SoftDeletes;
protected $fillable = [
'name',
'email',
'phone',
'password',
'locale',
'profile_picture',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role' => UserRole::class,
'is_active' => 'boolean',
'last_login_at' => 'datetime',
'dsgvo_accepted_at' => 'datetime',
'dsgvo_notice_accepted_at' => 'datetime',
];
}
public function sendPasswordResetNotification($token): void
{
$this->notify(new ResetPasswordNotification($token));
}
public function isAdmin(): bool
{
return $this->role === UserRole::Admin;
}
public function isCoach(): bool
{
return $this->role === UserRole::Coach;
}
public function isParentRep(): bool
{
return $this->role === UserRole::ParentRep;
}
public function isStaff(): bool
{
return in_array($this->role, [UserRole::Admin, UserRole::Coach]);
}
public function canAccessAdminPanel(): bool
{
return $this->isStaff() || $this->isParentRep();
}
public function canViewActivityLog(): bool
{
return $this->id === 1 && $this->isAdmin();
}
public function children(): BelongsToMany
{
return $this->belongsToMany(Player::class, 'parent_player', 'parent_id', 'player_id')
->withPivot('relationship_label', 'created_at');
}
/**
* Team-IDs, auf die der User Zugriff hat (über seine Kinder).
* Direkte DB-Query ohne Model-Hydration.
*/
public function accessibleTeamIds(): Collection
{
return $this->children()->distinct()->pluck('players.team_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function caterings(): HasMany
{
return $this->hasMany(EventCatering::class);
}
public function createdInvitations(): HasMany
{
return $this->hasMany(Invitation::class, 'created_by');
}
public function coachTeams(): BelongsToMany
{
return $this->belongsToMany(Team::class, 'team_user')
->withPivot('created_at');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function getAvatarUrl(): ?string
{
if ($this->profile_picture) {
return asset('storage/' . $this->profile_picture);
}
return null;
}
public function getInitials(): string
{
$parts = explode(' ', trim($this->name));
if (count($parts) >= 2) {
return mb_strtoupper(mb_substr($parts[0], 0, 1) . mb_substr(end($parts), 0, 1));
}
return mb_strtoupper(mb_substr($this->name, 0, 2));
}
public function isRestorable(): bool
{
return $this->trashed() && $this->deleted_at->diffInDays(now()) < 7;
}
public function isDsgvoRestricted(): bool
{
if ($this->role !== UserRole::User) {
return false;
}
return !$this->isDsgvoConfirmed();
}
public function needsDsgvoBanner(): bool
{
if ($this->role !== UserRole::User) {
return false;
}
return !$this->isDsgvoConfirmed();
}
public function dsgvoBannerState(): ?string
{
if ($this->role !== UserRole::User) {
return null;
}
if ($this->dsgvo_consent_file === null) {
return 'upload_required';
}
if ($this->dsgvo_accepted_at === null) {
return 'pending_confirmation';
}
return null;
}
public function hasDsgvoConsent(): bool
{
return $this->dsgvo_consent_file !== null;
}
public function isDsgvoConfirmed(): bool
{
return $this->dsgvo_accepted_at !== null;
}
public function dsgvoAcceptedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'dsgvo_accepted_by')->withTrashed();
}
public function getOrphanedChildren(): Collection
{
return $this->children()
->withCount('parents')
->get()
->filter(fn (Player $child) => $child->parents_count <= 1);
}
}