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