Files
WebAPP/app/Models/Event.php
Rhino 2e24a40d68 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>
2026-03-02 07:30:37 +01:00

292 lines
8.6 KiB
PHP
Executable File

<?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);
}
}