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,432 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\EventCatering;
use App\Models\EventTimekeeper;
use App\Models\File;
use App\Models\FileCategory;
use App\Models\Location;
use App\Models\Setting;
use App\Models\Team;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Services\HtmlSanitizerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EventController extends Controller
{
public function __construct(private HtmlSanitizerService $sanitizer) {}
public function index(Request $request): View
{
$query = Event::with(['team', 'participants'])
->withCount([
'caterings as caterings_yes_count' => fn ($q) => $q->where('status', 'yes'),
'timekeepers as timekeepers_yes_count' => fn ($q) => $q->where('status', 'yes'),
])
->latest('start_at');
// Team-Scoping: Coach/ParentRep sehen nur eigene Teams (V04)
$user = auth()->user();
if (!$user->isAdmin()) {
$teamIds = $user->isCoach()
? $user->coachTeams()->pluck('teams.id')
: $user->accessibleTeamIds();
$query->whereIn('team_id', $teamIds);
}
if ($request->filled('team_id')) {
$query->forTeam($request->team_id);
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$events = $query->paginate(20)->withQueryString();
$teams = Team::active()->orderBy('name')->get();
$trashedEvents = Event::onlyTrashed()->with('team')->latest('deleted_at')->get();
return view('admin.events.index', compact('events', 'teams', 'trashedEvents'));
}
public function create(): View
{
$teams = Team::active()->orderBy('name')->get();
$types = EventType::cases();
$statuses = EventStatus::cases();
$teamParents = $this->getTeamParents();
$eventDefaults = $this->getEventDefaults();
$knownLocations = Location::orderBy('name')->get();
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->get();
return view('admin.events.create', compact('teams', 'types', 'statuses', 'teamParents', 'eventDefaults', 'knownLocations', 'fileCategories'));
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validateEvent($request);
$validated['description_html'] = $this->sanitizer->sanitize($validated['description_html'] ?? '');
$this->normalizeMinFields($validated);
$event = Event::create($validated);
$event->created_by = $request->user()->id;
$event->updated_by = $request->user()->id;
$event->save();
$this->createParticipantsForTeam($event);
$this->syncAssignments($event, $request);
$this->saveKnownLocation($validated, $request->input('location_name'));
$this->syncEventFiles($event, $request);
ActivityLog::logWithChanges('created', __('admin.log_event_created', ['title' => $event->title]), 'Event', $event->id, null, ['title' => $event->title, 'team' => $event->team->name ?? '', 'type' => $event->type->value, 'status' => $event->status->value]);
return redirect()->route('admin.events.index')
->with('success', __('admin.event_created'));
}
public function edit(Event $event): View
{
// Team-Scoping: Nicht-Admins dürfen nur Events ihrer Teams sehen (V04)
$user = auth()->user();
if (!$user->isAdmin()) {
$teamIds = $user->isCoach()
? $user->coachTeams()->pluck('teams.id')->toArray()
: $user->accessibleTeamIds()->toArray();
if (!in_array($event->team_id, $teamIds)) {
abort(403);
}
}
$teams = Team::active()->orderBy('name')->get();
$types = EventType::cases();
$statuses = EventStatus::cases();
$teamParents = $this->getTeamParents();
$eventDefaults = $this->getEventDefaults();
$event->syncParticipants(auth()->id());
$participantRelations = $event->type === EventType::Meeting
? ['participants.user']
: ['participants.player'];
$event->load(array_merge($participantRelations, ['caterings', 'timekeepers', 'files.category']));
$assignedCatering = $event->caterings->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
$assignedTimekeeper = $event->timekeepers->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
$knownLocations = Location::orderBy('name')->get();
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->get();
return view('admin.events.edit', compact('event', 'teams', 'types', 'statuses', 'teamParents', 'assignedCatering', 'assignedTimekeeper', 'eventDefaults', 'knownLocations', 'fileCategories'));
}
public function update(Request $request, Event $event): RedirectResponse
{
$validated = $this->validateEvent($request);
$validated['description_html'] = $this->sanitizer->sanitize($validated['description_html'] ?? '');
$this->normalizeMinFields($validated);
$oldData = ['title' => $event->title, 'team_id' => $event->team_id, 'type' => $event->type->value, 'status' => $event->status->value, 'start_at' => $event->start_at?->toDateTimeString()];
$oldTeamId = $event->team_id;
$event->update($validated);
$event->updated_by = $request->user()->id;
$event->save();
if ($oldTeamId !== (int) $validated['team_id']) {
$event->participants()->delete();
$this->createParticipantsForTeam($event);
} else {
$event->syncParticipants($request->user()->id);
}
$this->syncAssignments($event, $request);
$this->saveKnownLocation($validated, $request->input('location_name'));
$this->syncEventFiles($event, $request);
$newData = ['title' => $event->title, 'team_id' => $event->team_id, 'type' => $event->type->value, 'status' => $event->status->value, 'start_at' => $event->start_at?->toDateTimeString()];
ActivityLog::logWithChanges('updated', __('admin.log_event_updated', ['title' => $event->title]), 'Event', $event->id, $oldData, $newData);
return redirect()->route('admin.events.index')
->with('success', __('admin.event_updated'));
}
public function updateParticipant(Request $request, Event $event)
{
$validated = $request->validate([
'participant_id' => ['required', 'integer'],
'status' => ['required', 'in:yes,no,unknown'],
]);
$participant = $event->participants()->where('id', $validated['participant_id'])->firstOrFail();
$oldStatus = $participant->status->value;
$participant->status = $validated['status'];
$participant->set_by_user_id = $request->user()->id;
$participant->responded_at = now();
$participant->save();
$participantLabel = $participant->user_id
? ($participant->user?->name ?? '')
: ($participant->player?->full_name ?? '');
ActivityLog::logWithChanges('participant_status_changed', __('admin.log_participant_changed', ['event' => $event->title, 'status' => $validated['status']]), 'Event', $event->id, ['status' => $oldStatus, 'player' => $participantLabel], ['status' => $validated['status']]);
return response()->json(['success' => true]);
}
public function destroy(Event $event): RedirectResponse
{
ActivityLog::logWithChanges('deleted', __('admin.log_event_deleted', ['title' => $event->title]), 'Event', $event->id, ['title' => $event->title, 'team' => $event->team->name ?? ''], null);
$event->deleted_by = auth()->id();
$event->save();
$event->delete();
return redirect()->route('admin.events.index')
->with('success', __('admin.event_deleted'));
}
public function restore(int $id): RedirectResponse
{
$event = Event::onlyTrashed()->findOrFail($id);
$event->deleted_by = null;
$event->save();
$event->restore();
ActivityLog::logWithChanges('restored', __('admin.log_event_restored', ['title' => $event->title]), 'Event', $event->id, null, ['title' => $event->title, 'team' => $event->team->name ?? '']);
return redirect()->route('admin.events.index')
->with('success', __('admin.event_restored'));
}
private function validateEvent(Request $request): array
{
$request->validate([
'catering_users' => ['nullable', 'array'],
'catering_users.*' => ['integer', 'exists:users,id'],
'timekeeper_users' => ['nullable', 'array'],
'timekeeper_users.*' => ['integer', 'exists:users,id'],
'existing_files' => ['nullable', 'array'],
'existing_files.*' => ['integer', 'exists:files,id'],
'new_files' => ['nullable', 'array'],
'new_files.*' => ['file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
'new_file_categories' => ['nullable', 'array'],
'new_file_categories.*' => ['integer', 'exists:file_categories,id'],
]);
$validated = $request->validate([
'team_id' => ['required', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
$team = Team::find($value);
if (!$team || !$team->is_active) {
$fail(__('validation.exists', ['attribute' => $attribute]));
}
}],
'type' => ['required', 'in:' . implode(',', array_column(EventType::cases(), 'value'))],
'title' => ['required', 'string', 'max:255'],
'start_date' => ['required', 'date'],
'start_time' => ['required', 'date_format:H:i'],
'status' => ['required', 'in:' . implode(',', array_column(EventStatus::cases(), 'value'))],
'location_name' => ['nullable', 'string', 'max:255'],
'address_text' => ['nullable', 'string', 'max:500'],
'location_lat' => ['nullable', 'numeric', 'between:-90,90'],
'location_lng' => ['nullable', 'numeric', 'between:-180,180'],
'description_html' => ['nullable', 'string', 'max:50000'],
'min_players' => ['nullable', 'integer', 'min:0', 'max:30'],
'min_catering' => ['nullable', 'integer', 'min:0', 'max:8'],
'min_timekeepers' => ['nullable', 'integer', 'min:0', 'max:8'],
'opponent' => ['nullable', 'string', 'max:100'],
'score_home' => ['nullable', 'integer', 'min:0', 'max:99'],
'score_away' => ['nullable', 'integer', 'min:0', 'max:99'],
]);
// Datum und Uhrzeit zusammenführen
$validated['start_at'] = $validated['start_date'] . ' ' . $validated['start_time'];
$validated['end_at'] = null;
unset($validated['start_date'], $validated['start_time']);
return $validated;
}
private function createParticipantsForTeam(Event $event): void
{
if ($event->type === EventType::Meeting) {
$event->syncMeetingParticipants(auth()->id());
return;
}
$activePlayers = $event->team->activePlayers;
$userId = auth()->id();
$records = $activePlayers->map(fn ($player) => [
'event_id' => $event->id,
'player_id' => $player->id,
'status' => ParticipantStatus::Unknown->value,
'set_by_user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
])->toArray();
if (!empty($records)) {
$event->participants()->insert($records);
}
}
private function syncAssignments(Event $event, Request $request): void
{
// Auswärtsspiele und Besprechungen haben kein Catering/Zeitnehmer
if (!$event->type->hasCatering() && !$event->type->hasTimekeepers()) {
return;
}
$cateringUsers = $request->input('catering_users', []);
$timekeeperUsers = $request->input('timekeeper_users', []);
// Catering: set assigned users to Yes, remove unassigned admin-set entries
$event->caterings()->whereNotIn('user_id', $cateringUsers)->where('status', CateringStatus::Yes)->delete();
foreach ($cateringUsers as $userId) {
$catering = EventCatering::where('event_id', $event->id)->where('user_id', $userId)->first();
if (!$catering) {
$catering = new EventCatering(['event_id' => $event->id]);
$catering->user_id = $userId;
}
$catering->status = CateringStatus::Yes;
$catering->save();
}
// Timekeeper: same pattern
$event->timekeepers()->whereNotIn('user_id', $timekeeperUsers)->where('status', CateringStatus::Yes)->delete();
foreach ($timekeeperUsers as $userId) {
$timekeeper = EventTimekeeper::where('event_id', $event->id)->where('user_id', $userId)->first();
if (!$timekeeper) {
$timekeeper = new EventTimekeeper(['event_id' => $event->id]);
$timekeeper->user_id = $userId;
}
$timekeeper->status = CateringStatus::Yes;
$timekeeper->save();
}
}
private function normalizeMinFields(array &$validated): void
{
foreach (['min_players', 'min_catering', 'min_timekeepers'] as $field) {
$validated[$field] = isset($validated[$field]) && $validated[$field] !== '' ? (int) $validated[$field] : null;
}
// Auswärtsspiele und Besprechungen: kein Catering/Zeitnehmer
$type = EventType::tryFrom($validated['type'] ?? '');
if ($type && !$type->hasCatering()) {
$validated['min_catering'] = null;
}
if ($type && !$type->hasTimekeepers()) {
$validated['min_timekeepers'] = null;
}
// Nicht-Spiel-Typen: kein Gegner/Ergebnis
if ($type && !$type->isGameType()) {
$validated['opponent'] = null;
$validated['score_home'] = null;
$validated['score_away'] = null;
}
}
private function getEventDefaults(): array
{
$defaults = [];
foreach (['home_game', 'away_game', 'training', 'tournament', 'meeting', 'other'] as $type) {
$defaults[$type] = [
'min_players' => Setting::get("default_min_players_{$type}"),
'min_catering' => Setting::get("default_min_catering_{$type}"),
'min_timekeepers' => Setting::get("default_min_timekeepers_{$type}"),
];
}
return $defaults;
}
private function getTeamParents(): array
{
return Team::active()->with(['players' => fn ($q) => $q->active(), 'players.parents' => fn ($q) => $q->active()])
->get()
->mapWithKeys(fn ($team) => [
$team->id => $team->players->flatMap(fn ($p) => $p->parents)->unique('id')
->map(fn ($u) => ['id' => $u->id, 'name' => $u->name])
->values()
->toArray(),
])
->toArray();
}
private function syncEventFiles(Event $event, Request $request): void
{
// Attach existing files from library
$existingFileIds = $request->input('existing_files', []);
// Upload new files
$newFileIds = [];
$newFiles = $request->file('new_files', []);
$newCategories = $request->input('new_file_categories', []);
foreach ($newFiles as $index => $uploadedFile) {
if (!$uploadedFile || !$uploadedFile->isValid()) {
continue;
}
$categoryId = $newCategories[$index] ?? null;
if (!$categoryId) {
continue;
}
$extension = $uploadedFile->guessExtension();
$storedName = Str::uuid() . '.' . $extension;
Storage::disk('local')->putFileAs('files', $uploadedFile, $storedName);
$file = new File([
'file_category_id' => $categoryId,
'original_name' => $uploadedFile->getClientOriginalName(),
'mime_type' => $uploadedFile->getClientMimeType(),
'size' => $uploadedFile->getSize(),
]);
$file->stored_name = $storedName;
$file->disk = 'private';
$file->uploaded_by = auth()->id();
$file->save();
$newFileIds[] = $file->id;
}
// Merge existing + new file IDs and sync
$allFileIds = array_merge(
array_map('intval', $existingFileIds),
$newFileIds
);
$event->files()->sync($allFileIds);
}
private function saveKnownLocation(array $validated, ?string $locationName): void
{
if (empty($locationName) || empty($validated['address_text'])) {
return;
}
Location::updateOrCreate(
['name' => $locationName],
[
'address_text' => $validated['address_text'],
'location_lat' => $validated['location_lat'] ?? null,
'location_lng' => $validated['location_lng'] ?? null,
]
);
}
}