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:
15
app/Enums/CateringStatus.php
Executable file
15
app/Enums/CateringStatus.php
Executable file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CateringStatus: string
|
||||
{
|
||||
case Yes = 'yes';
|
||||
case No = 'no';
|
||||
case Unknown = 'unknown';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __("ui.enums.catering_status.{$this->value}");
|
||||
}
|
||||
}
|
||||
15
app/Enums/EventStatus.php
Executable file
15
app/Enums/EventStatus.php
Executable file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EventStatus: string
|
||||
{
|
||||
case Published = 'published';
|
||||
case Cancelled = 'cancelled';
|
||||
case Draft = 'draft';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __("ui.enums.event_status.{$this->value}");
|
||||
}
|
||||
}
|
||||
38
app/Enums/EventType.php
Executable file
38
app/Enums/EventType.php
Executable file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EventType: string
|
||||
{
|
||||
case HomeGame = 'home_game';
|
||||
case AwayGame = 'away_game';
|
||||
case Training = 'training';
|
||||
case Tournament = 'tournament';
|
||||
case Meeting = 'meeting';
|
||||
case Other = 'other';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __("ui.enums.event_type.{$this->value}");
|
||||
}
|
||||
|
||||
public function isGameType(): bool
|
||||
{
|
||||
return in_array($this, [self::HomeGame, self::AwayGame]);
|
||||
}
|
||||
|
||||
public function hasCatering(): bool
|
||||
{
|
||||
return !in_array($this, [self::AwayGame, self::Meeting]);
|
||||
}
|
||||
|
||||
public function hasTimekeepers(): bool
|
||||
{
|
||||
return !in_array($this, [self::AwayGame, self::Meeting]);
|
||||
}
|
||||
|
||||
public function hasPlayerParticipants(): bool
|
||||
{
|
||||
return $this !== self::Meeting;
|
||||
}
|
||||
}
|
||||
15
app/Enums/ParticipantStatus.php
Executable file
15
app/Enums/ParticipantStatus.php
Executable file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ParticipantStatus: string
|
||||
{
|
||||
case Yes = 'yes';
|
||||
case No = 'no';
|
||||
case Unknown = 'unknown';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __("ui.enums.participant_status.{$this->value}");
|
||||
}
|
||||
}
|
||||
11
app/Enums/UserRole.php
Executable file
11
app/Enums/UserRole.php
Executable file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserRole: string
|
||||
{
|
||||
case Admin = 'admin';
|
||||
case Coach = 'coach';
|
||||
case ParentRep = 'parent_rep';
|
||||
case User = 'user';
|
||||
}
|
||||
264
app/Http/Controllers/Admin/ActivityLogController.php
Normal file
264
app/Http/Controllers/Admin/ActivityLogController.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCatering;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\EventTimekeeper;
|
||||
use App\Models\Player;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ActivityLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
if (!auth()->user()->canViewActivityLog()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'category' => ['nullable', 'string', 'in:auth,users,players,events,files,settings,dsgvo'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$query = ActivityLog::with('user')->latest('created_at');
|
||||
|
||||
// Filter: Kategorie
|
||||
if ($request->filled('category')) {
|
||||
$actionMap = [
|
||||
'auth' => ['login', 'logout', 'login_failed', 'registered'],
|
||||
'users' => ['updated', 'toggled_active', 'role_changed', 'password_reset', 'deleted', 'restored'],
|
||||
'players' => ['created', 'updated', 'deleted', 'restored', 'parent_assigned', 'parent_removed'],
|
||||
'events' => ['created', 'updated', 'deleted', 'participant_status_changed'],
|
||||
'files' => ['uploaded', 'deleted'],
|
||||
'settings' => ['updated'],
|
||||
'dsgvo' => ['dsgvo_consent_uploaded', 'dsgvo_consent_confirmed', 'dsgvo_consent_revoked', 'dsgvo_consent_removed', 'dsgvo_consent_rejected', 'account_self_deleted', 'child_auto_deactivated'],
|
||||
];
|
||||
|
||||
$category = $request->input('category');
|
||||
if (isset($actionMap[$category])) {
|
||||
if ($category === 'auth' || $category === 'dsgvo') {
|
||||
$query->whereIn('action', $actionMap[$category]);
|
||||
} else {
|
||||
$modelTypeMap = [
|
||||
'users' => 'User',
|
||||
'players' => 'Player',
|
||||
'events' => 'Event',
|
||||
'files' => 'File',
|
||||
'settings' => 'Setting',
|
||||
];
|
||||
$query->where('model_type', $modelTypeMap[$category] ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter: Datum von
|
||||
if ($request->filled('from')) {
|
||||
$query->whereDate('created_at', '>=', $request->input('from'));
|
||||
}
|
||||
|
||||
// Filter: Datum bis
|
||||
if ($request->filled('to')) {
|
||||
$query->whereDate('created_at', '<=', $request->input('to'));
|
||||
}
|
||||
|
||||
$logs = $query->paginate(30)->withQueryString();
|
||||
|
||||
return view('admin.activity-logs.index', compact('logs'));
|
||||
}
|
||||
|
||||
public function revert(ActivityLog $log): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->canViewActivityLog()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$old = $log->properties['old'] ?? [];
|
||||
|
||||
$revertableActions = ['deleted', 'toggled_active', 'role_changed', 'status_changed', 'participant_status_changed'];
|
||||
if (!in_array($log->action, $revertableActions) || !$log->model_id) {
|
||||
return back()->with('error', __('admin.log_revert_not_possible'));
|
||||
}
|
||||
|
||||
$success = match ($log->action) {
|
||||
'deleted' => $this->revertDelete($log),
|
||||
'toggled_active' => $this->revertToggleActive($log, $old),
|
||||
'role_changed' => $this->revertRoleChange($log, $old),
|
||||
'status_changed' => $this->revertStatusChange($log, $old),
|
||||
'participant_status_changed' => $this->revertParticipantStatus($log, $old),
|
||||
default => false,
|
||||
};
|
||||
|
||||
if (!$success) {
|
||||
return back()->with('error', __('admin.log_revert_not_possible'));
|
||||
}
|
||||
|
||||
ActivityLog::log('reverted', __('admin.log_reverted', ['desc' => Str::limit($log->description, 80)]), $log->model_type, $log->model_id);
|
||||
|
||||
return back()->with('success', __('admin.log_revert_success'));
|
||||
}
|
||||
|
||||
private function revertDelete(ActivityLog $log): bool
|
||||
{
|
||||
return match ($log->model_type) {
|
||||
'User' => $this->restoreModel(User::class, $log->model_id),
|
||||
'Player' => $this->restoreModel(Player::class, $log->model_id),
|
||||
'Event' => $this->restoreEvent($log->model_id),
|
||||
'Comment' => $this->restoreComment($log->model_id),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function restoreModel(string $class, int $id): bool
|
||||
{
|
||||
$model = $class::onlyTrashed()->find($id);
|
||||
if (!$model) {
|
||||
return false;
|
||||
}
|
||||
$model->restore();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function restoreEvent(int $id): bool
|
||||
{
|
||||
$event = Event::onlyTrashed()->find($id);
|
||||
if (!$event) {
|
||||
return false;
|
||||
}
|
||||
$event->deleted_by = null;
|
||||
$event->save();
|
||||
$event->restore();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function restoreComment(int $id): bool
|
||||
{
|
||||
$comment = Comment::find($id);
|
||||
if (!$comment || !$comment->isDeleted()) {
|
||||
return false;
|
||||
}
|
||||
$comment->deleted_at = null;
|
||||
$comment->deleted_by = null;
|
||||
$comment->save();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function revertToggleActive(ActivityLog $log, array $old): bool
|
||||
{
|
||||
$model = match ($log->model_type) {
|
||||
'User' => User::withTrashed()->find($log->model_id),
|
||||
'Player' => Player::withTrashed()->find($log->model_id),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$model || !isset($old['is_active'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$model->is_active = filter_var($old['is_active'], FILTER_VALIDATE_BOOLEAN);
|
||||
$model->save();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function revertRoleChange(ActivityLog $log, array $old): bool
|
||||
{
|
||||
if ($log->model_type !== 'User' || !isset($old['role'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enum-Validierung: Nur gültige Rollen-Werte akzeptieren (V02)
|
||||
$validRoles = array_column(UserRole::cases(), 'value');
|
||||
if (!in_array($old['role'], $validRoles, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = User::withTrashed()->find($log->model_id);
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Selbst-Rollen-Änderung verhindern
|
||||
if ($user->id === auth()->id()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nicht-Admins dürfen keine Admin-Rolle zuweisen oder Admin-Rollen rückgängig machen
|
||||
if (!auth()->user()->isAdmin() && ($user->isAdmin() || $old['role'] === 'admin')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user->role = $old['role'];
|
||||
$user->save();
|
||||
return true;
|
||||
}
|
||||
|
||||
private function revertStatusChange(ActivityLog $log, array $old): bool
|
||||
{
|
||||
if (!isset($old['status']) || $log->model_type !== 'Event') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userId = $log->properties['old']['user_id'] ?? $log->properties['new']['user_id'] ?? null;
|
||||
if (!$userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$catering = EventCatering::where('event_id', $log->model_id)->where('user_id', $userId)->first();
|
||||
if ($catering) {
|
||||
$catering->update(['status' => $old['status']]);
|
||||
return true;
|
||||
}
|
||||
|
||||
$timekeeper = EventTimekeeper::where('event_id', $log->model_id)->where('user_id', $userId)->first();
|
||||
if ($timekeeper) {
|
||||
$timekeeper->update(['status' => $old['status']]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function revertParticipantStatus(ActivityLog $log, array $old): bool
|
||||
{
|
||||
if (!isset($old['status']) || $log->model_type !== 'Event') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Spieler-ID bevorzugen, Namens-Suche als Fallback (DB-agnostisch)
|
||||
$participant = EventParticipant::where('event_id', $log->model_id)
|
||||
->when(
|
||||
isset($old['participant_id']),
|
||||
fn ($q) => $q->where('id', $old['participant_id']),
|
||||
fn ($q) => $q->when(
|
||||
isset($old['player']),
|
||||
fn ($q2) => $q2->whereHas('player', function ($pq) use ($old) {
|
||||
$parts = explode(' ', $old['player'], 2);
|
||||
if (count($parts) === 2) {
|
||||
$pq->where('first_name', $parts[0])->where('last_name', $parts[1]);
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
->first();
|
||||
|
||||
if (!$participant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$participant->status = $old['status'];
|
||||
$participant->set_by_user_id = auth()->id();
|
||||
$participant->responded_at = now();
|
||||
$participant->save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
22
app/Http/Controllers/Admin/CommentController.php
Executable file
22
app/Http/Controllers/Admin/CommentController.php
Executable file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Comment;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function softDelete(Comment $comment): RedirectResponse
|
||||
{
|
||||
$comment->deleted_at = now();
|
||||
$comment->deleted_by = auth()->id();
|
||||
$comment->save();
|
||||
|
||||
ActivityLog::logWithChanges('deleted', __('admin.log_comment_deleted', ['event' => $comment->event?->title]), 'Event', $comment->event_id, ['comment' => mb_substr($comment->body, 0, 100), 'event' => $comment->event?->title], null);
|
||||
|
||||
return back()->with('success', __('events.comment_removed'));
|
||||
}
|
||||
}
|
||||
81
app/Http/Controllers/Admin/DashboardController.php
Executable file
81
app/Http/Controllers/Admin/DashboardController.php
Executable file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invitation;
|
||||
use App\Models\Player;
|
||||
use App\Models\User;
|
||||
use App\Services\SupportApiService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$stats = [
|
||||
'users' => User::count(),
|
||||
'players' => Player::count(),
|
||||
'upcoming_events' => Event::where('status', EventStatus::Published)
|
||||
->where('start_at', '>=', now())->count(),
|
||||
'open_invitations' => Invitation::whereNull('accepted_at')
|
||||
->where('expires_at', '>', now())->count(),
|
||||
];
|
||||
|
||||
// Events mit vielen offenen Rückmeldungen
|
||||
$eventsWithOpenResponses = Event::with('team')
|
||||
->where('status', EventStatus::Published)
|
||||
->where('start_at', '>=', now())
|
||||
->withCount(['participants as open_count' => function ($q) {
|
||||
$q->where('status', ParticipantStatus::Unknown);
|
||||
}])
|
||||
->orderByDesc('open_count')
|
||||
->limit(10)
|
||||
->get()
|
||||
->filter(fn ($e) => $e->open_count > 0)
|
||||
->take(5);
|
||||
|
||||
// DSGVO: User mit hochgeladenem Dokument, aber ohne Admin-Bestätigung
|
||||
$pendingDsgvoUsers = User::where('role', UserRole::User)
|
||||
->whereNotNull('dsgvo_consent_file')
|
||||
->whereNull('dsgvo_accepted_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Letzte 10 DSGVO-Ereignisse
|
||||
$dsgvoEvents = ActivityLog::with('user')
|
||||
->whereIn('action', [
|
||||
'dsgvo_consent_uploaded',
|
||||
'dsgvo_consent_confirmed',
|
||||
'dsgvo_consent_revoked',
|
||||
'dsgvo_consent_removed',
|
||||
'dsgvo_consent_rejected',
|
||||
'account_self_deleted',
|
||||
'child_auto_deactivated',
|
||||
])
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Update-Check (cached 24h, nur wenn registriert)
|
||||
$supportService = app(SupportApiService::class);
|
||||
if ($supportService->isRegistered()) {
|
||||
$supportService->checkForUpdate();
|
||||
}
|
||||
$hasUpdate = $supportService->hasUpdate();
|
||||
$updateVersion = $hasUpdate
|
||||
? (Cache::get('support.update_check')['latest_version'] ?? null)
|
||||
: null;
|
||||
|
||||
return view('admin.dashboard', compact(
|
||||
'stats', 'eventsWithOpenResponses', 'pendingDsgvoUsers', 'dsgvoEvents',
|
||||
'hasUpdate', 'updateVersion'
|
||||
));
|
||||
}
|
||||
}
|
||||
432
app/Http/Controllers/Admin/EventController.php
Executable file
432
app/Http/Controllers/Admin/EventController.php
Executable 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/Http/Controllers/Admin/FileCategoryController.php
Normal file
64
app/Http/Controllers/Admin/FileCategoryController.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\FileCategory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FileCategoryController extends Controller
|
||||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$slug = Str::slug($request->name);
|
||||
|
||||
// Ensure unique slug
|
||||
$originalSlug = $slug;
|
||||
$counter = 1;
|
||||
while (FileCategory::where('slug', $slug)->exists()) {
|
||||
$slug = $originalSlug . '-' . $counter++;
|
||||
}
|
||||
|
||||
$maxOrder = FileCategory::max('sort_order') ?? 0;
|
||||
|
||||
FileCategory::create([
|
||||
'name' => $request->name,
|
||||
'slug' => $slug,
|
||||
'sort_order' => $maxOrder + 1,
|
||||
]);
|
||||
|
||||
return back()->with('success', __('admin.category_created'));
|
||||
}
|
||||
|
||||
public function update(Request $request, FileCategory $category): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$category->update([
|
||||
'name' => $request->name,
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
return back()->with('success', __('admin.category_updated'));
|
||||
}
|
||||
|
||||
public function destroy(FileCategory $category): RedirectResponse
|
||||
{
|
||||
if ($category->files()->exists()) {
|
||||
return back()->with('error', __('admin.category_not_empty'));
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
|
||||
return back()->with('success', __('admin.category_deleted'));
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Admin/FileController.php
Normal file
83
app/Http/Controllers/Admin/FileController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\File;
|
||||
use App\Models\FileCategory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FileController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$categories = FileCategory::ordered()->withCount('files')->get();
|
||||
$activeCategory = $request->query('category');
|
||||
|
||||
$query = File::with(['category', 'uploader'])->latest();
|
||||
|
||||
if ($activeCategory) {
|
||||
$query->whereHas('category', fn ($q) => $q->where('slug', $activeCategory));
|
||||
}
|
||||
|
||||
$files = $query->paginate(25)->withQueryString();
|
||||
|
||||
return view('admin.files.index', compact('categories', 'files', 'activeCategory'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$categories = FileCategory::active()->ordered()->get();
|
||||
return view('admin.files.create', compact('categories'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => ['required', 'file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
|
||||
'file_category_id' => ['required', 'exists:file_categories,id'],
|
||||
]);
|
||||
|
||||
$uploadedFile = $request->file('file');
|
||||
$extension = $uploadedFile->guessExtension();
|
||||
$storedName = Str::uuid() . '.' . $extension;
|
||||
|
||||
Storage::disk('local')->putFileAs('files', $uploadedFile, $storedName);
|
||||
|
||||
$file = new File([
|
||||
'file_category_id' => $request->file_category_id,
|
||||
'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();
|
||||
|
||||
ActivityLog::logWithChanges('uploaded', __('admin.log_file_uploaded', ['name' => $file->original_name]), 'File', $file->id, null, ['name' => $file->original_name, 'category' => $file->category->name ?? '']);
|
||||
|
||||
return redirect()->route('admin.files.index')
|
||||
->with('success', __('admin.file_uploaded'));
|
||||
}
|
||||
|
||||
public function destroy(File $file): RedirectResponse
|
||||
{
|
||||
// Path-Traversal-Schutz (V15)
|
||||
if (str_contains($file->stored_name, '..') || str_contains($file->stored_name, '/')) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
ActivityLog::logWithChanges('deleted', __('admin.log_file_deleted', ['name' => $file->original_name]), 'File', $file->id, ['name' => $file->original_name, 'category' => $file->category->name ?? ''], null);
|
||||
|
||||
Storage::disk('local')->delete('files/' . $file->stored_name);
|
||||
$file->delete();
|
||||
|
||||
return back()->with('success', __('admin.file_deleted'));
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Admin/GeocodingController.php
Executable file
24
app/Http/Controllers/Admin/GeocodingController.php
Executable file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\GeocodingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GeocodingController extends Controller
|
||||
{
|
||||
public function __construct(private GeocodingService $geocoding) {}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => ['required', 'string', 'min:3', 'max:255'],
|
||||
]);
|
||||
|
||||
$results = $this->geocoding->search($request->q);
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Admin/InvitationController.php
Executable file
63
app/Http/Controllers/Admin/InvitationController.php
Executable file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Invitation;
|
||||
use App\Models\Player;
|
||||
use App\Services\InvitationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
public function __construct(private InvitationService $invitationService) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$invitations = Invitation::with(['creator', 'players.team'])
|
||||
->latest('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.invitations.index', compact('invitations'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$players = Player::with('team')->active()->orderBy('last_name')->get();
|
||||
|
||||
return view('admin.invitations.create', compact('players'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'expires_in_days' => ['required', 'integer', 'min:1', 'max:90'],
|
||||
'player_ids' => ['nullable', 'array'],
|
||||
'player_ids.*' => ['exists:players,id'],
|
||||
]);
|
||||
|
||||
$invitation = $this->invitationService->createInvitation($validated, $request->user());
|
||||
|
||||
$link = route('register', $invitation->raw_token);
|
||||
|
||||
ActivityLog::logWithChanges('created', __('admin.log_invitation_created', ['email' => $validated['email'] ?? '–']), 'User', null, null, ['email' => $validated['email'] ?? '–']);
|
||||
|
||||
return redirect()->route('admin.invitations.index')
|
||||
->with('success', __('admin.invitation_created', ['link' => $link]));
|
||||
}
|
||||
|
||||
public function destroy(Invitation $invitation): RedirectResponse
|
||||
{
|
||||
if ($invitation->isAccepted()) {
|
||||
return back()->with('error', __('admin.invitation_already_used'));
|
||||
}
|
||||
|
||||
$invitation->delete();
|
||||
|
||||
return back()->with('success', __('admin.invitation_deleted'));
|
||||
}
|
||||
}
|
||||
255
app/Http/Controllers/Admin/ListGeneratorController.php
Normal file
255
app/Http/Controllers/Admin/ListGeneratorController.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\File;
|
||||
use App\Models\FileCategory;
|
||||
use App\Models\Player;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ListGeneratorController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
$teams = Team::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('admin.list-generator.create', compact('teams'));
|
||||
}
|
||||
|
||||
public function store(Request $request): View
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'subtitle' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:2000',
|
||||
'team_id' => 'nullable|exists:teams,id',
|
||||
'source' => 'required|in:players,parents,freetext',
|
||||
'freetext_rows' => 'nullable|required_if:source,freetext|string|max:50000',
|
||||
'columns' => 'nullable|array',
|
||||
'custom_columns' => 'nullable|array',
|
||||
'custom_columns.*' => 'string|max:100',
|
||||
]);
|
||||
|
||||
$columns = $this->buildColumns($validated);
|
||||
$rows = $this->buildRows($validated, $columns);
|
||||
|
||||
// Auto-detect orientation and font size for single-page PDF
|
||||
$colCount = count($columns);
|
||||
$rowCount = count($rows);
|
||||
$orientation = $colCount > 4 ? 'landscape' : 'portrait';
|
||||
|
||||
// Font size calculation for single-page fit
|
||||
$fontSize = 10;
|
||||
if ($rowCount > 35) {
|
||||
$fontSize = 7;
|
||||
} elseif ($rowCount > 25) {
|
||||
$fontSize = 8;
|
||||
} elseif ($rowCount > 15) {
|
||||
$fontSize = 9;
|
||||
}
|
||||
|
||||
$viewData = [
|
||||
'title' => $validated['title'],
|
||||
'subtitle' => $validated['subtitle'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'columns' => $columns,
|
||||
'rows' => $rows,
|
||||
'generatedAt' => now(),
|
||||
'orientation' => $orientation,
|
||||
'fontSize' => $fontSize,
|
||||
];
|
||||
|
||||
// Generate PDF
|
||||
$pdf = Pdf::loadView('admin.list-generator.document', $viewData)
|
||||
->setPaper('a4', $orientation);
|
||||
|
||||
$pdfContent = $pdf->output();
|
||||
|
||||
// Save to file library
|
||||
$category = FileCategory::where('slug', 'allgemein')->firstOrFail();
|
||||
$storedName = Str::uuid() . '.pdf';
|
||||
Storage::disk('local')->put('files/' . $storedName, $pdfContent);
|
||||
|
||||
$file = new File([
|
||||
'file_category_id' => $category->id,
|
||||
'original_name' => Str::slug($validated['title']) . '.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => strlen($pdfContent),
|
||||
]);
|
||||
$file->stored_name = $storedName;
|
||||
$file->disk = 'private';
|
||||
$file->uploaded_by = auth()->id();
|
||||
$file->save();
|
||||
|
||||
ActivityLog::log('created', __('admin.log_list_generated', ['title' => $validated['title']]), 'File', $file->id);
|
||||
|
||||
return view('admin.list-generator.result', [
|
||||
'title' => $validated['title'],
|
||||
'subtitle' => $validated['subtitle'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
'columns' => $columns,
|
||||
'rows' => $rows,
|
||||
'file' => $file,
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildColumns(array $data): array
|
||||
{
|
||||
$columns = ['name' => __('ui.name')];
|
||||
$selected = $data['columns'] ?? [];
|
||||
|
||||
$playerColumns = [
|
||||
'team' => __('admin.nav_teams'),
|
||||
'jersey_number' => __('admin.jersey_number'),
|
||||
'birth_year' => __('admin.birth_year'),
|
||||
'parents' => __('admin.parents'),
|
||||
'photo_permission' => __('admin.photo_permission'),
|
||||
];
|
||||
|
||||
$parentColumns = [
|
||||
'team' => __('admin.nav_teams'),
|
||||
'email' => __('ui.email'),
|
||||
'phone' => __('admin.phone'),
|
||||
'children' => __('admin.children'),
|
||||
];
|
||||
|
||||
$available = match ($data['source']) {
|
||||
'players' => $playerColumns,
|
||||
'parents' => $parentColumns,
|
||||
default => [],
|
||||
};
|
||||
|
||||
foreach ($selected as $col) {
|
||||
if (isset($available[$col])) {
|
||||
$columns[$col] = $available[$col];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (($data['custom_columns'] ?? []) as $i => $header) {
|
||||
$header = trim($header);
|
||||
if ($header !== '') {
|
||||
$columns['custom_' . $i] = $header;
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
private function buildRows(array $data, array $columns): array
|
||||
{
|
||||
if ($data['source'] === 'freetext') {
|
||||
return $this->buildFreetextRows($data);
|
||||
}
|
||||
|
||||
if ($data['source'] === 'players') {
|
||||
return $this->buildPlayerRows($data, $columns);
|
||||
}
|
||||
|
||||
return $this->buildParentRows($data, $columns);
|
||||
}
|
||||
|
||||
private function buildPlayerRows(array $data, array $columns): array
|
||||
{
|
||||
$query = Player::with(['team', 'parents'])->where('is_active', true);
|
||||
|
||||
if (!empty($data['team_id'])) {
|
||||
$query->where('team_id', $data['team_id']);
|
||||
}
|
||||
|
||||
$query->orderBy('last_name')->orderBy('first_name');
|
||||
$players = $query->get();
|
||||
|
||||
$rows = [];
|
||||
foreach ($players as $player) {
|
||||
$row = ['name' => $player->full_name];
|
||||
|
||||
if (isset($columns['team'])) {
|
||||
$row['team'] = $player->team->name ?? '–';
|
||||
}
|
||||
if (isset($columns['jersey_number'])) {
|
||||
$row['jersey_number'] = $player->jersey_number ?? '–';
|
||||
}
|
||||
if (isset($columns['birth_year'])) {
|
||||
$row['birth_year'] = $player->birth_year ?? '–';
|
||||
}
|
||||
if (isset($columns['parents'])) {
|
||||
$row['parents'] = $player->parents->map(fn ($p) => $p->name)->implode(', ') ?: '–';
|
||||
}
|
||||
if (isset($columns['photo_permission'])) {
|
||||
$row['photo_permission'] = $player->photo_permission ? __('ui.yes') : __('ui.no');
|
||||
}
|
||||
|
||||
foreach ($columns as $key => $header) {
|
||||
if (str_starts_with($key, 'custom_')) {
|
||||
$row[$key] = '';
|
||||
}
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function buildParentRows(array $data, array $columns): array
|
||||
{
|
||||
$query = User::with('children.team')->where('is_active', true);
|
||||
|
||||
if (!empty($data['team_id'])) {
|
||||
$query->whereHas('children', fn ($q) => $q->where('team_id', $data['team_id']));
|
||||
}
|
||||
|
||||
$query->orderBy('name');
|
||||
$users = $query->get();
|
||||
|
||||
$rows = [];
|
||||
foreach ($users as $user) {
|
||||
$row = ['name' => $user->name];
|
||||
|
||||
if (isset($columns['team'])) {
|
||||
$teamNames = $user->children->pluck('team.name')->filter()->unique()->implode(', ');
|
||||
$row['team'] = $teamNames ?: '–';
|
||||
}
|
||||
if (isset($columns['email'])) {
|
||||
$row['email'] = $user->email;
|
||||
}
|
||||
if (isset($columns['phone'])) {
|
||||
$row['phone'] = $user->phone ?? '–';
|
||||
}
|
||||
if (isset($columns['children'])) {
|
||||
$row['children'] = $user->children->map(fn ($c) => $c->first_name)->implode(', ') ?: '–';
|
||||
}
|
||||
|
||||
foreach ($columns as $key => $header) {
|
||||
if (str_starts_with($key, 'custom_')) {
|
||||
$row[$key] = '';
|
||||
}
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function buildFreetextRows(array $data): array
|
||||
{
|
||||
$lines = array_filter(
|
||||
array_map('trim', explode("\n", $data['freetext_rows'] ?? '')),
|
||||
fn ($line) => $line !== ''
|
||||
);
|
||||
|
||||
// Maximum 200 Zeilen — DoS-Schutz (V10)
|
||||
$lines = array_slice($lines, 0, 200);
|
||||
|
||||
return array_map(fn ($line) => ['name' => $line], array_values($lines));
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/Admin/LocationController.php
Executable file
57
app/Http/Controllers/Admin/LocationController.php
Executable file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Location;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LocationController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$locations = Location::orderBy('name')->get();
|
||||
|
||||
return view('admin.locations.index', compact('locations'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'address_text' => ['nullable', 'string', 'max:500'],
|
||||
'location_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'location_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
]);
|
||||
|
||||
Location::create($validated);
|
||||
|
||||
return redirect()->route('admin.locations.index')
|
||||
->with('success', __('admin.location_created'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Location $location): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'address_text' => ['nullable', 'string', 'max:500'],
|
||||
'location_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'location_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
]);
|
||||
|
||||
$location->update($validated);
|
||||
|
||||
return redirect()->route('admin.locations.index')
|
||||
->with('success', __('admin.location_updated'));
|
||||
}
|
||||
|
||||
public function destroy(Location $location): RedirectResponse
|
||||
{
|
||||
$location->delete();
|
||||
|
||||
return redirect()->route('admin.locations.index')
|
||||
->with('success', __('admin.location_deleted'));
|
||||
}
|
||||
}
|
||||
269
app/Http/Controllers/Admin/PlayerController.php
Executable file
269
app/Http/Controllers/Admin/PlayerController.php
Executable file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\Player;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PlayerController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Player::with(['team', 'parents']);
|
||||
|
||||
// Team-Scoping: Coach sieht nur Spieler eigener 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->where('team_id', $request->team_id);
|
||||
}
|
||||
|
||||
// Sortierung
|
||||
$sortable = ['name', 'team', 'jersey_number', 'is_active', 'created_at'];
|
||||
$sort = in_array($request->input('sort'), $sortable) ? $request->input('sort') : 'created_at';
|
||||
$direction = $request->input('direction') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
match ($sort) {
|
||||
'name' => $query->orderBy('last_name', $direction)->orderBy('first_name', $direction),
|
||||
'team' => $query->orderBy(
|
||||
Team::select('name')->whereColumn('teams.id', 'players.team_id'), $direction
|
||||
),
|
||||
'jersey_number' => $query->orderBy(DB::raw('CASE WHEN jersey_number IS NULL THEN 1 ELSE 0 END'))->orderBy('jersey_number', $direction),
|
||||
'is_active' => $query->orderBy('is_active', $direction),
|
||||
default => $query->orderBy('created_at', $direction),
|
||||
};
|
||||
|
||||
$players = $query->paginate(20)->withQueryString();
|
||||
$teams = Team::active()->orderBy('name')->get();
|
||||
$trashedPlayers = Player::onlyTrashed()
|
||||
->with('team')
|
||||
->where('deleted_at', '>=', now()->subDays(7))
|
||||
->latest('deleted_at')
|
||||
->get();
|
||||
|
||||
return view('admin.players.index', compact('players', 'teams', 'trashedPlayers', 'sort', 'direction'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$teams = Team::active()->orderBy('name')->get();
|
||||
return view('admin.players.create', compact('teams'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'first_name' => ['required', 'string', 'max:100'],
|
||||
'last_name' => ['required', 'string', 'max:100'],
|
||||
'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]));
|
||||
}
|
||||
}],
|
||||
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
|
||||
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
|
||||
'is_active' => ['boolean'],
|
||||
'photo_permission' => ['boolean'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
$validated['photo_permission'] = $request->boolean('photo_permission');
|
||||
|
||||
$player = Player::create($validated);
|
||||
|
||||
if ($player->is_active) {
|
||||
Event::syncParticipantsForTeam($player->team_id, auth()->id());
|
||||
}
|
||||
|
||||
ActivityLog::logWithChanges('created', __('admin.log_player_created', ['name' => $player->full_name]), 'Player', $player->id, null, ['name' => $player->full_name, 'team' => $player->team->name ?? $player->team_id]);
|
||||
|
||||
return redirect()->route('admin.players.index')
|
||||
->with('success', __('admin.player_created'));
|
||||
}
|
||||
|
||||
public function edit(Player $player)
|
||||
{
|
||||
$player->load('parents');
|
||||
$teams = Team::active()->orderBy('name')->get();
|
||||
$users = User::active()->orderBy('name')->get();
|
||||
|
||||
return view('admin.players.edit', compact('player', 'teams', 'users'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Player $player)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'first_name' => ['required', 'string', 'max:100'],
|
||||
'last_name' => ['required', 'string', 'max:100'],
|
||||
'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]));
|
||||
}
|
||||
}],
|
||||
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
|
||||
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
|
||||
'photo_permission' => ['boolean'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
|
||||
]);
|
||||
|
||||
$validated['photo_permission'] = $request->boolean('photo_permission');
|
||||
|
||||
// Handle profile picture upload
|
||||
if ($request->hasFile('profile_picture')) {
|
||||
if ($player->profile_picture) {
|
||||
Storage::disk('public')->delete($player->profile_picture);
|
||||
}
|
||||
$file = $request->file('profile_picture');
|
||||
$storedName = 'avatars/' . Str::uuid() . '.' . $file->guessExtension();
|
||||
Storage::disk('public')->putFileAs('', $file, $storedName);
|
||||
$validated['profile_picture'] = $storedName;
|
||||
} else {
|
||||
unset($validated['profile_picture']);
|
||||
}
|
||||
|
||||
$oldData = ['first_name' => $player->first_name, 'last_name' => $player->last_name, 'team_id' => $player->team_id, 'birth_year' => $player->birth_year, 'jersey_number' => $player->jersey_number, 'photo_permission' => $player->photo_permission];
|
||||
|
||||
$oldTeamId = $player->team_id;
|
||||
$player->update($validated);
|
||||
|
||||
// Sync: neues Team bekommt den Spieler, bei Team-Wechsel auch altes Team
|
||||
if ($player->is_active) {
|
||||
Event::syncParticipantsForTeam($player->team_id, auth()->id());
|
||||
}
|
||||
if ($oldTeamId !== (int) $validated['team_id']) {
|
||||
Event::syncParticipantsForTeam($oldTeamId, auth()->id());
|
||||
}
|
||||
|
||||
$newData = ['first_name' => $player->first_name, 'last_name' => $player->last_name, 'team_id' => $player->team_id, 'birth_year' => $player->birth_year, 'jersey_number' => $player->jersey_number, 'photo_permission' => $player->photo_permission];
|
||||
ActivityLog::logWithChanges('updated', __('admin.log_player_updated', ['name' => $player->full_name]), 'Player', $player->id, $oldData, $newData);
|
||||
|
||||
return redirect()->route('admin.players.index')
|
||||
->with('success', __('admin.player_updated'));
|
||||
}
|
||||
|
||||
public function quickUpdate(Request $request, Player $player)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'team_id' => ['sometimes', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
|
||||
$team = Team::find($value);
|
||||
if (!$team || !$team->is_active) {
|
||||
$fail(__('validation.exists', ['attribute' => $attribute]));
|
||||
}
|
||||
}],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'photo_permission' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$oldTeamId = $player->team_id;
|
||||
$player->update($validated);
|
||||
|
||||
// Sync future events when team or active status changes
|
||||
if (isset($validated['team_id']) || isset($validated['is_active'])) {
|
||||
if ($player->is_active) {
|
||||
Event::syncParticipantsForTeam($player->team_id, auth()->id());
|
||||
}
|
||||
if (isset($validated['team_id']) && $oldTeamId !== (int) $validated['team_id']) {
|
||||
Event::syncParticipantsForTeam($oldTeamId, auth()->id());
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function assignParent(Request $request, Player $player)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'parent_id' => ['required', 'exists:users,id'],
|
||||
'relationship_label' => ['nullable', 'string', 'max:50'],
|
||||
]);
|
||||
|
||||
$player->parents()->syncWithoutDetaching([
|
||||
$validated['parent_id'] => [
|
||||
'relationship_label' => $validated['relationship_label'] ?? null,
|
||||
'created_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$parent = User::find($validated['parent_id']);
|
||||
ActivityLog::logWithChanges('parent_assigned', __('admin.log_parent_assigned', ['parent' => $parent?->name, 'player' => $player->full_name]), 'Player', $player->id, null, ['parent' => $parent?->name, 'player' => $player->full_name]);
|
||||
|
||||
return back()->with('success', __('admin.parent_assigned'));
|
||||
}
|
||||
|
||||
public function removeParent(Player $player, User $user)
|
||||
{
|
||||
$player->parents()->detach($user->id);
|
||||
|
||||
ActivityLog::logWithChanges('parent_removed', __('admin.log_parent_removed', ['parent' => $user->name, 'player' => $player->full_name]), 'Player', $player->id, ['parent' => $user->name, 'player' => $player->full_name], null);
|
||||
|
||||
return back()->with('success', __('admin.parent_removed'));
|
||||
}
|
||||
|
||||
public function removePicture(Player $player): RedirectResponse
|
||||
{
|
||||
if ($player->profile_picture) {
|
||||
Storage::disk('public')->delete($player->profile_picture);
|
||||
$player->update(['profile_picture' => null]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('admin.picture_removed'));
|
||||
}
|
||||
|
||||
public function toggleActive(Player $player): RedirectResponse
|
||||
{
|
||||
$oldActive = $player->is_active;
|
||||
|
||||
$player->update(['is_active' => !$player->is_active]);
|
||||
|
||||
// Sync future events
|
||||
Event::syncParticipantsForTeam($player->team_id, auth()->id());
|
||||
|
||||
$status = $player->is_active ? __('admin.activated') : __('admin.deactivated');
|
||||
ActivityLog::logWithChanges('toggled_active', __('admin.log_player_toggled', ['name' => $player->full_name, 'status' => $status]), 'Player', $player->id, ['is_active' => $oldActive], ['is_active' => $player->is_active]);
|
||||
|
||||
return back()->with('success', __('admin.player_toggled', ['status' => $status]));
|
||||
}
|
||||
|
||||
public function destroy(Player $player): RedirectResponse
|
||||
{
|
||||
$player->delete();
|
||||
|
||||
ActivityLog::logWithChanges('deleted', __('admin.log_player_deleted', ['name' => $player->full_name]), 'Player', $player->id, ['name' => $player->full_name, 'team' => $player->team->name ?? ''], null);
|
||||
|
||||
return redirect()->route('admin.players.index')->with('success', __('admin.player_deleted'));
|
||||
}
|
||||
|
||||
public function restore(int $id): RedirectResponse
|
||||
{
|
||||
$player = Player::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if (! $player->isRestorable()) {
|
||||
return back()->with('error', __('admin.restore_expired'));
|
||||
}
|
||||
|
||||
$player->restore();
|
||||
|
||||
ActivityLog::logWithChanges('restored', __('admin.log_player_restored', ['name' => $player->full_name]), 'Player', $player->id, null, ['name' => $player->full_name]);
|
||||
|
||||
return back()->with('success', __('admin.player_restored'));
|
||||
}
|
||||
}
|
||||
418
app/Http/Controllers/Admin/SettingsController.php
Executable file
418
app/Http/Controllers/Admin/SettingsController.php
Executable file
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\FileCategory;
|
||||
use App\Models\Setting;
|
||||
use App\Services\HtmlSanitizerService;
|
||||
use App\Services\SupportApiService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function __construct(private HtmlSanitizerService $sanitizer) {}
|
||||
|
||||
public function edit(): View
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$allSettings = Setting::all()->keyBy('key');
|
||||
|
||||
// Event-Default-Keys separieren — immer alle liefern (auch wenn nicht in DB)
|
||||
$eventDefaults = collect();
|
||||
foreach (['home_game', 'away_game', 'training', 'tournament', 'meeting'] as $type) {
|
||||
foreach (['players', 'catering', 'timekeepers'] as $field) {
|
||||
$key = "default_min_{$field}_{$type}";
|
||||
$eventDefaults[$key] = $allSettings[$key]->value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Visibility-Settings separieren
|
||||
$visibilitySettings = $allSettings->filter(fn ($s) => str_starts_with($s->key, 'visibility_'));
|
||||
|
||||
$settings = $allSettings->filter(fn ($s) =>
|
||||
!str_starts_with($s->key, 'default_min_') &&
|
||||
!str_starts_with($s->key, 'visibility_') &&
|
||||
!str_starts_with($s->key, 'impressum_html_') &&
|
||||
!str_starts_with($s->key, 'datenschutz_html_') &&
|
||||
!str_starts_with($s->key, 'password_reset_email_')
|
||||
);
|
||||
$fileCategories = FileCategory::ordered()->withCount('files')->get();
|
||||
|
||||
// Verfügbare Sprachen und deren locale-spezifische Settings
|
||||
$availableLocales = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
|
||||
$localeSettings = [];
|
||||
foreach ($availableLocales as $locale) {
|
||||
$localeSettings[$locale] = [
|
||||
'impressum_html' => $allSettings["impressum_html_{$locale}"]->value ?? '',
|
||||
'datenschutz_html' => $allSettings["datenschutz_html_{$locale}"]->value ?? '',
|
||||
'password_reset_email' => $allSettings["password_reset_email_{$locale}"]->value ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// Support-API-Status (nur für Admin-Tab)
|
||||
$supportService = app(SupportApiService::class);
|
||||
$isRegistered = $supportService->isRegistered();
|
||||
$installationId = $isRegistered ? ($supportService->readInstalled()['installation_id'] ?? null) : null;
|
||||
$updateInfo = Cache::get('support.update_check');
|
||||
|
||||
$mailConfig = [
|
||||
'mailer' => config('mail.default'),
|
||||
'host' => config('mail.mailers.smtp.host'),
|
||||
'port' => config('mail.mailers.smtp.port'),
|
||||
'username' => config('mail.mailers.smtp.username'),
|
||||
'password' => config('mail.mailers.smtp.password'),
|
||||
'encryption' => config('mail.mailers.smtp.scheme', 'tls'),
|
||||
'from_address' => config('mail.from.address'),
|
||||
'from_name' => config('mail.from.name'),
|
||||
];
|
||||
|
||||
return view('admin.settings.edit', compact(
|
||||
'settings', 'eventDefaults', 'fileCategories', 'visibilitySettings',
|
||||
'isRegistered', 'installationId', 'updateInfo',
|
||||
'availableLocales', 'localeSettings', 'mailConfig'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403, 'Nur Admins koennen Einstellungen aendern.');
|
||||
}
|
||||
|
||||
// Favicon-Upload verarbeiten (vor der normalen Settings-Schleife)
|
||||
if ($request->hasFile('favicon')) {
|
||||
$request->validate([
|
||||
'favicon' => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:512',
|
||||
]);
|
||||
|
||||
// Altes Favicon löschen
|
||||
$oldFavicon = Setting::get('app_favicon');
|
||||
if ($oldFavicon) {
|
||||
Storage::disk('public')->delete($oldFavicon);
|
||||
}
|
||||
|
||||
$file = $request->file('favicon');
|
||||
$filename = Str::uuid() . '.' . $file->guessExtension();
|
||||
$path = $file->storeAs('favicon', $filename, 'public');
|
||||
Setting::set('app_favicon', $path);
|
||||
} elseif ($request->has('remove_favicon')) {
|
||||
$oldFavicon = Setting::get('app_favicon');
|
||||
if ($oldFavicon) {
|
||||
Storage::disk('public')->delete($oldFavicon);
|
||||
}
|
||||
Setting::set('app_favicon', null);
|
||||
}
|
||||
|
||||
$inputSettings = $request->input('settings', []);
|
||||
|
||||
// Whitelist: Nur erlaubte Setting-Keys akzeptieren
|
||||
$allowedLocales = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
|
||||
$allowedPrefixes = ['default_min_', 'visibility_'];
|
||||
$allowedLocaleKeys = [];
|
||||
foreach ($allowedLocales as $loc) {
|
||||
$allowedLocaleKeys[] = "impressum_html_{$loc}";
|
||||
$allowedLocaleKeys[] = "datenschutz_html_{$loc}";
|
||||
$allowedLocaleKeys[] = "password_reset_email_{$loc}";
|
||||
}
|
||||
|
||||
$oldValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
|
||||
|
||||
foreach ($inputSettings as $key => $value) {
|
||||
// Whitelist-Pruefung: Nur bekannte Keys oder erlaubte Prefixe
|
||||
$isExistingSetting = Setting::where('key', $key)->exists();
|
||||
$isAllowedLocaleKey = in_array($key, $allowedLocaleKeys);
|
||||
$isAllowedPrefix = false;
|
||||
foreach ($allowedPrefixes as $prefix) {
|
||||
if (str_starts_with($key, $prefix)) {
|
||||
$isAllowedPrefix = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isExistingSetting && !$isAllowedLocaleKey && !$isAllowedPrefix) {
|
||||
continue; // Unbekannten Key ignorieren
|
||||
}
|
||||
|
||||
$setting = Setting::where('key', $key)->first();
|
||||
|
||||
if ($setting) {
|
||||
if ($setting->type === 'html' || $setting->type === 'richtext') {
|
||||
$value = $this->sanitizer->sanitize($value ?? '');
|
||||
} elseif ($setting->type === 'number') {
|
||||
$value = $value !== null && $value !== '' ? (int) $value : null;
|
||||
} else {
|
||||
$value = strip_tags($value ?? '');
|
||||
}
|
||||
$setting->update(['value' => $value]);
|
||||
} elseif ($isAllowedLocaleKey) {
|
||||
// Locale-suffixed legal/email settings: upsert mit HTML-Sanitisierung
|
||||
$value = $this->sanitizer->sanitize($value ?? '');
|
||||
$localeSetting = Setting::where('key', $key)->first();
|
||||
if ($localeSetting) {
|
||||
$localeSetting->update(['value' => $value]);
|
||||
} else {
|
||||
$localeSetting = new Setting(['label' => $key, 'type' => 'html', 'value' => $value]);
|
||||
$localeSetting->key = $key;
|
||||
$localeSetting->save();
|
||||
}
|
||||
} elseif ($isAllowedPrefix) {
|
||||
// Event-Defaults / Visibility: upsert — anlegen wenn nicht vorhanden
|
||||
$prefixSetting = new Setting([
|
||||
'label' => $key,
|
||||
'type' => 'number',
|
||||
'value' => $value !== null && $value !== '' ? (int) $value : null,
|
||||
]);
|
||||
$prefixSetting->key = $key;
|
||||
$prefixSetting->save();
|
||||
}
|
||||
}
|
||||
|
||||
Setting::clearCache();
|
||||
|
||||
$newValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
|
||||
ActivityLog::logWithChanges('updated', __('admin.log_settings_updated'), 'Setting', null, $oldValues, $newValues);
|
||||
|
||||
// License key validation when changed
|
||||
$newLicenseKey = $inputSettings['license_key'] ?? null;
|
||||
$oldLicenseKey = $oldValues['license_key'] ?? null;
|
||||
if ($newLicenseKey && $newLicenseKey !== $oldLicenseKey) {
|
||||
$supportService = app(SupportApiService::class);
|
||||
$result = $supportService->validateLicense($newLicenseKey);
|
||||
if ($result && !($result['valid'] ?? false)) {
|
||||
session()->flash('warning', __('admin.license_invalid'));
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', __('admin.settings_saved'));
|
||||
}
|
||||
|
||||
public function updateMail(Request $request): RedirectResponse
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$mailer = $request->input('mail_mailer', 'log');
|
||||
|
||||
if ($mailer === 'smtp') {
|
||||
$request->validate([
|
||||
'mail_host' => 'required|string|max:255',
|
||||
'mail_port' => 'required|integer|min:1|max:65535',
|
||||
'mail_username' => 'required|string|max:255',
|
||||
'mail_password' => 'required|string|max:255',
|
||||
'mail_from_address' => 'required|email|max:255',
|
||||
'mail_from_name' => 'nullable|string|max:255',
|
||||
'mail_encryption' => 'required|in:tls,ssl,none',
|
||||
]);
|
||||
|
||||
$encryption = $request->input('mail_encryption');
|
||||
$this->updateEnvValues([
|
||||
'MAIL_MAILER' => 'smtp',
|
||||
'MAIL_HOST' => $request->input('mail_host'),
|
||||
'MAIL_PORT' => $request->input('mail_port'),
|
||||
'MAIL_USERNAME' => $request->input('mail_username'),
|
||||
'MAIL_PASSWORD' => $request->input('mail_password'),
|
||||
'MAIL_FROM_ADDRESS' => $request->input('mail_from_address'),
|
||||
'MAIL_FROM_NAME' => $request->input('mail_from_name', config('app.name')),
|
||||
'MAIL_SCHEME' => $encryption === 'none' ? '' : $encryption,
|
||||
]);
|
||||
} else {
|
||||
$this->updateEnvValues([
|
||||
'MAIL_MAILER' => 'log',
|
||||
]);
|
||||
}
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
return back()->with('success', __('admin.mail_saved'))->withFragment('mail');
|
||||
}
|
||||
|
||||
public function testMail(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
return response()->json(['success' => false, 'message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'mail_host' => 'required|string|max:255',
|
||||
'mail_port' => 'required|integer|min:1|max:65535',
|
||||
'mail_username' => 'required|string|max:255',
|
||||
'mail_password' => 'required|string|max:255',
|
||||
'mail_encryption' => 'required|in:tls,ssl,none',
|
||||
]);
|
||||
|
||||
try {
|
||||
$encryption = $request->input('mail_encryption');
|
||||
$tls = ($encryption !== 'none');
|
||||
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
|
||||
$request->input('mail_host'),
|
||||
(int) $request->input('mail_port'),
|
||||
$tls,
|
||||
);
|
||||
$transport->setUsername($request->input('mail_username'));
|
||||
$transport->setPassword($request->input('mail_password'));
|
||||
$transport->start();
|
||||
$transport->stop();
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('admin.mail_test_success')]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateEnvValues(array $values): void
|
||||
{
|
||||
$envPath = base_path('.env');
|
||||
$envContent = file_get_contents($envPath);
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
if ($value === '' || $value === null) {
|
||||
$replacement = "# {$key}=";
|
||||
} else {
|
||||
$quotedValue = str_contains($value, ' ') || str_contains($value, '#')
|
||||
? '"' . str_replace('"', '\\"', $value) . '"'
|
||||
: $value;
|
||||
$replacement = "{$key}={$quotedValue}";
|
||||
}
|
||||
|
||||
if (preg_match("/^#?\s*{$key}=.*/m", $envContent)) {
|
||||
$envContent = preg_replace("/^#?\s*{$key}=.*/m", $replacement, $envContent);
|
||||
} else {
|
||||
$envContent .= "\n{$replacement}";
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($envPath, $envContent);
|
||||
}
|
||||
|
||||
public function destroyDemoData(Request $request): RedirectResponse
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
// Löschreihenfolge beachtet FK-Constraints
|
||||
DB::table('activity_logs')->delete();
|
||||
DB::table('comments')->delete();
|
||||
DB::table('event_participants')->delete();
|
||||
DB::table('event_catering')->delete();
|
||||
DB::table('event_timekeepers')->delete();
|
||||
DB::table('event_faq')->delete();
|
||||
DB::table('event_file')->delete();
|
||||
DB::table('events')->delete();
|
||||
DB::table('parent_player')->delete();
|
||||
DB::table('players')->delete();
|
||||
DB::table('team_user')->delete();
|
||||
DB::table('team_file')->delete();
|
||||
DB::table('teams')->delete();
|
||||
DB::table('invitation_players')->delete();
|
||||
DB::table('invitations')->delete();
|
||||
DB::table('locations')->delete();
|
||||
DB::table('faq')->delete();
|
||||
DB::table('users')->where('id', '!=', auth()->id())->delete();
|
||||
|
||||
// Hochgeladene Dateien aus Storage entfernen + DB-Einträge löschen
|
||||
$files = DB::table('files')->get();
|
||||
foreach ($files as $file) {
|
||||
Storage::disk('private')->delete($file->path);
|
||||
}
|
||||
DB::table('files')->delete();
|
||||
|
||||
// Profilbilder-Ordner leeren (Admin-Bild bleibt via DB erhalten)
|
||||
$adminAvatar = auth()->user()->profile_picture;
|
||||
foreach (Storage::disk('public')->files('avatars') as $avatarFile) {
|
||||
if ($adminAvatar && str_contains($avatarFile, $adminAvatar)) {
|
||||
continue;
|
||||
}
|
||||
Storage::disk('public')->delete($avatarFile);
|
||||
}
|
||||
|
||||
ActivityLog::log('deleted', __('admin.demo_data_deleted'));
|
||||
|
||||
return redirect()->route('admin.settings.edit')
|
||||
->with('success', __('admin.demo_data_deleted'));
|
||||
}
|
||||
|
||||
public function factoryReset(Request $request): RedirectResponse
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'password' => ['required', 'current_password'],
|
||||
'confirmation' => ['required', 'in:RESET-BESTÄTIGT'],
|
||||
]);
|
||||
|
||||
// 1. Alle hochgeladenen Dateien entfernen
|
||||
Storage::disk('private')->deleteDirectory('files');
|
||||
Storage::disk('public')->deleteDirectory('avatars');
|
||||
Storage::disk('public')->deleteDirectory('favicon');
|
||||
Storage::disk('public')->deleteDirectory('dsgvo');
|
||||
|
||||
// 2. FK-Constraints deaktivieren (DB-agnostisch)
|
||||
$driver = DB::getDriverName();
|
||||
if ($driver === 'sqlite') {
|
||||
DB::statement('PRAGMA foreign_keys = OFF;');
|
||||
} else {
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0;');
|
||||
}
|
||||
|
||||
// 3. Alle Tabellen leeren
|
||||
$tables = [
|
||||
'activity_logs', 'comments', 'event_participants',
|
||||
'event_catering', 'event_timekeepers', 'event_faq',
|
||||
'event_file', 'events', 'parent_player', 'players',
|
||||
'team_user', 'team_file', 'teams',
|
||||
'invitation_players', 'invitations', 'locations',
|
||||
'faq', 'files', 'file_categories', 'settings',
|
||||
'users', 'sessions', 'cache', 'cache_locks',
|
||||
];
|
||||
foreach ($tables as $table) {
|
||||
DB::table($table)->delete();
|
||||
}
|
||||
|
||||
// 4. FK-Constraints reaktivieren
|
||||
if ($driver === 'sqlite') {
|
||||
DB::statement('PRAGMA foreign_keys = ON;');
|
||||
} else {
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1;');
|
||||
}
|
||||
|
||||
// 5. storage/installed entfernen → Installer-Modus aktivieren
|
||||
$installedFile = storage_path('installed');
|
||||
if (file_exists($installedFile)) {
|
||||
unlink($installedFile);
|
||||
}
|
||||
|
||||
// 6. Caches leeren
|
||||
Artisan::call('cache:clear');
|
||||
Artisan::call('config:clear');
|
||||
Artisan::call('view:clear');
|
||||
Artisan::call('route:clear');
|
||||
|
||||
// 7. Session invalidieren + Logout
|
||||
auth()->logout();
|
||||
request()->session()->invalidate();
|
||||
request()->session()->regenerateToken();
|
||||
|
||||
// 8. Redirect zum Installer
|
||||
return redirect()->route('install.requirements');
|
||||
}
|
||||
}
|
||||
209
app/Http/Controllers/Admin/StatisticsController.php
Normal file
209
app/Http/Controllers/Admin/StatisticsController.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?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\Event;
|
||||
use App\Models\EventCatering;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\EventTimekeeper;
|
||||
use App\Models\Player;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StatisticsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('statistics', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'team_id' => ['nullable', 'integer', 'exists:teams,id'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$query = Event::with(['team'])
|
||||
->withCount([
|
||||
'participants as players_yes_count' => fn ($q) => $q->where('status', 'yes'),
|
||||
'caterings as caterings_yes_count' => fn ($q) => $q->where('status', 'yes'),
|
||||
'timekeepers as timekeepers_yes_count' => fn ($q) => $q->where('status', 'yes'),
|
||||
])
|
||||
->whereIn('type', [EventType::HomeGame, EventType::AwayGame])
|
||||
->where('status', EventStatus::Published);
|
||||
|
||||
if ($request->filled('team_id')) {
|
||||
$query->where('team_id', $request->team_id);
|
||||
}
|
||||
|
||||
if ($request->filled('from')) {
|
||||
$query->where('start_at', '>=', $request->from);
|
||||
}
|
||||
|
||||
if ($request->filled('to')) {
|
||||
$query->where('start_at', '<=', $request->to . ' 23:59:59');
|
||||
}
|
||||
|
||||
$games = $query->orderByDesc('start_at')->get();
|
||||
|
||||
// Statistiken berechnen
|
||||
$gamesWithScore = $games->filter(fn ($g) => $g->score_home !== null && $g->score_away !== null);
|
||||
|
||||
$wins = 0;
|
||||
$losses = 0;
|
||||
$draws = 0;
|
||||
|
||||
foreach ($gamesWithScore as $game) {
|
||||
if ($game->type === EventType::HomeGame) {
|
||||
if ($game->score_home > $game->score_away) {
|
||||
$wins++;
|
||||
} elseif ($game->score_home < $game->score_away) {
|
||||
$losses++;
|
||||
} else {
|
||||
$draws++;
|
||||
}
|
||||
} else {
|
||||
if ($game->score_away > $game->score_home) {
|
||||
$wins++;
|
||||
} elseif ($game->score_away < $game->score_home) {
|
||||
$losses++;
|
||||
} else {
|
||||
$draws++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$totalWithScore = $gamesWithScore->count();
|
||||
$winRate = $totalWithScore > 0 ? round(($wins / $totalWithScore) * 100) : 0;
|
||||
|
||||
// Chart-Daten
|
||||
$chartWinLoss = [
|
||||
'labels' => [__('admin.wins'), __('admin.losses'), __('admin.draws')],
|
||||
'data' => [$wins, $losses, $draws],
|
||||
'colors' => ['#22c55e', '#ef4444', '#9ca3af'],
|
||||
];
|
||||
|
||||
// Spieler-Teilnahme pro Spiel (nur die letzten 15 Spiele)
|
||||
$recentGames = $games->take(15)->reverse()->values();
|
||||
$chartPlayerParticipation = [
|
||||
'labels' => $recentGames->map(fn ($g) => $g->start_at->format('d.m.'))->toArray(),
|
||||
'data' => $recentGames->map(fn ($g) => $g->players_yes_count)->toArray(),
|
||||
];
|
||||
|
||||
// Eltern-Engagement (Catering + Zeitnehmer)
|
||||
$chartParentInvolvement = [
|
||||
'labels' => $recentGames->map(fn ($g) => $g->start_at->format('d.m.'))->toArray(),
|
||||
'catering' => $recentGames->map(fn ($g) => $g->caterings_yes_count)->toArray(),
|
||||
'timekeepers' => $recentGames->map(fn ($g) => $g->timekeepers_yes_count)->toArray(),
|
||||
];
|
||||
|
||||
$teams = Team::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
// ── Spieler-Rangliste ──────────────────────────────────
|
||||
$gameIds = $games->pluck('id');
|
||||
$totalGames = $games->count();
|
||||
|
||||
$playerRanking = collect();
|
||||
if ($totalGames > 0) {
|
||||
$playerRanking = EventParticipant::select('player_id', DB::raw('COUNT(*) as total_assigned'), DB::raw('SUM(CASE WHEN status = \'yes\' THEN 1 ELSE 0 END) as games_played'))
|
||||
->whereIn('event_id', $gameIds)
|
||||
->whereNotNull('player_id')
|
||||
->groupBy('player_id')
|
||||
->get()
|
||||
->map(function ($row) use ($totalGames) {
|
||||
$player = Player::withTrashed()->find($row->player_id);
|
||||
if (!$player) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'player' => $player,
|
||||
'games_played' => (int) $row->games_played,
|
||||
'total_assigned' => (int) $row->total_assigned,
|
||||
'total_games' => $totalGames,
|
||||
'rate' => $row->total_assigned > 0
|
||||
? round(($row->games_played / $row->total_assigned) * 100)
|
||||
: 0,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->sortByDesc('games_played')
|
||||
->values();
|
||||
}
|
||||
|
||||
// ── Eltern-Engagement-Rangliste ────────────────────────
|
||||
// Alle publizierten Events (nicht nur Spiele) mit gleichen Team/Datum-Filtern
|
||||
$allEventsQuery = Event::where('status', EventStatus::Published);
|
||||
|
||||
if ($request->filled('team_id')) {
|
||||
$allEventsQuery->where('team_id', $request->team_id);
|
||||
}
|
||||
if ($request->filled('from')) {
|
||||
$allEventsQuery->where('start_at', '>=', $request->from);
|
||||
}
|
||||
if ($request->filled('to')) {
|
||||
$allEventsQuery->where('start_at', '<=', $request->to . ' 23:59:59');
|
||||
}
|
||||
|
||||
$allEventIds = $allEventsQuery->pluck('id');
|
||||
|
||||
// Catering-Events (nur Typen die Catering haben)
|
||||
$cateringEventIds = $allEventsQuery->clone()
|
||||
->whereNotIn('type', [EventType::AwayGame, EventType::Meeting])
|
||||
->pluck('id');
|
||||
|
||||
// Zeitnehmer-Events (identisch wie Catering)
|
||||
$timekeeperEventIds = $cateringEventIds;
|
||||
|
||||
$cateringCounts = EventCatering::select('user_id', DB::raw('COUNT(*) as count'))
|
||||
->whereIn('event_id', $cateringEventIds)
|
||||
->where('status', CateringStatus::Yes)
|
||||
->groupBy('user_id')
|
||||
->pluck('count', 'user_id');
|
||||
|
||||
$timekeeperCounts = EventTimekeeper::select('user_id', DB::raw('COUNT(*) as count'))
|
||||
->whereIn('event_id', $timekeeperEventIds)
|
||||
->where('status', CateringStatus::Yes)
|
||||
->groupBy('user_id')
|
||||
->pluck('count', 'user_id');
|
||||
|
||||
$parentUserIds = $cateringCounts->keys()->merge($timekeeperCounts->keys())->unique();
|
||||
|
||||
$parentRanking = User::withTrashed()
|
||||
->whereIn('id', $parentUserIds)
|
||||
->get()
|
||||
->map(function ($user) use ($cateringCounts, $timekeeperCounts) {
|
||||
$catering = $cateringCounts->get($user->id, 0);
|
||||
$timekeeper = $timekeeperCounts->get($user->id, 0);
|
||||
|
||||
return (object) [
|
||||
'user' => $user,
|
||||
'catering_count' => $catering,
|
||||
'timekeeper_count' => $timekeeper,
|
||||
'total' => $catering + $timekeeper,
|
||||
];
|
||||
})
|
||||
->sortByDesc('total')
|
||||
->values();
|
||||
|
||||
$totalCateringEvents = $cateringEventIds->count();
|
||||
$totalTimekeeperEvents = $timekeeperEventIds->count();
|
||||
|
||||
return view('admin.statistics.index', compact(
|
||||
'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore',
|
||||
'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement',
|
||||
'playerRanking', 'totalGames',
|
||||
'parentRanking', 'totalCateringEvents', 'totalTimekeeperEvents'
|
||||
));
|
||||
}
|
||||
}
|
||||
126
app/Http/Controllers/Admin/SupportController.php
Normal file
126
app/Http/Controllers/Admin/SupportController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use App\Services\SupportApiService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SupportController extends Controller
|
||||
{
|
||||
public function __construct(private SupportApiService $supportService) {}
|
||||
|
||||
/**
|
||||
* Nur Admins duerfen den Support-Bereich nutzen (T05).
|
||||
*/
|
||||
private function authorizeAdmin(): void
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$this->authorizeAdmin();
|
||||
$registered = $this->supportService->isRegistered();
|
||||
$tickets = $registered ? ($this->supportService->getTickets() ?? []) : [];
|
||||
|
||||
return view('admin.support.index', compact('registered', 'tickets'));
|
||||
}
|
||||
|
||||
public function show(int $ticketId): View|RedirectResponse
|
||||
{
|
||||
$this->authorizeAdmin();
|
||||
|
||||
if (!$this->supportService->isRegistered()) {
|
||||
return redirect()->route('admin.support.index');
|
||||
}
|
||||
|
||||
$ticket = $this->supportService->getTicket($ticketId);
|
||||
if (!$ticket) {
|
||||
return redirect()->route('admin.support.index')
|
||||
->with('error', __('admin.support_ticket_not_found'));
|
||||
}
|
||||
|
||||
return view('admin.support.show', compact('ticket'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeAdmin();
|
||||
|
||||
$request->validate([
|
||||
'subject' => 'required|string|max:255',
|
||||
'message' => 'required|string|max:5000',
|
||||
'category' => 'required|string|in:bug,feature,question,other',
|
||||
]);
|
||||
|
||||
$result = $this->supportService->createTicket([
|
||||
'subject' => $request->input('subject'),
|
||||
'message' => $request->input('message'),
|
||||
'category' => $request->input('category'),
|
||||
'system_info' => $this->supportService->getSystemInfo(),
|
||||
'license_key' => Setting::get('license_key'),
|
||||
]);
|
||||
|
||||
if (!$result) {
|
||||
return back()->withInput()
|
||||
->with('error', __('admin.support_submit_failed'));
|
||||
}
|
||||
|
||||
return redirect()->route('admin.support.show', $result['ticket_id'] ?? 0)
|
||||
->with('success', __('admin.support_ticket_created'));
|
||||
}
|
||||
|
||||
public function reply(Request $request, int $ticketId): RedirectResponse
|
||||
{
|
||||
$this->authorizeAdmin();
|
||||
|
||||
$request->validate([
|
||||
'message' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$result = $this->supportService->replyToTicket($ticketId, [
|
||||
'message' => $request->input('message'),
|
||||
]);
|
||||
|
||||
if (!$result) {
|
||||
return back()->withInput()
|
||||
->with('error', __('admin.support_reply_failed'));
|
||||
}
|
||||
|
||||
return redirect()->route('admin.support.show', $ticketId)
|
||||
->with('success', __('admin.support_reply_sent'));
|
||||
}
|
||||
|
||||
public function register(): RedirectResponse
|
||||
{
|
||||
$this->authorizeAdmin();
|
||||
|
||||
$data = [
|
||||
'app_name' => Setting::get('app_name', config('app.name')),
|
||||
'app_url' => config('app.url'),
|
||||
'app_version' => config('app.version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'db_driver' => config('database.default'),
|
||||
'installed_at' => $this->supportService->readInstalled()['installed_at'] ?? now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$logoUrl = $this->supportService->getLogoUrl();
|
||||
if ($logoUrl) {
|
||||
$data['logo_url'] = $logoUrl;
|
||||
}
|
||||
|
||||
$result = $this->supportService->register($data);
|
||||
|
||||
if ($result) {
|
||||
return back()->with('success', __('admin.registration_success'));
|
||||
}
|
||||
|
||||
return back()->with('error', __('admin.registration_failed'));
|
||||
}
|
||||
}
|
||||
209
app/Http/Controllers/Admin/TeamController.php
Executable file
209
app/Http/Controllers/Admin/TeamController.php
Executable file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\File;
|
||||
use App\Models\FileCategory;
|
||||
use App\Models\Player;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TeamController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$teams = Team::withCount(['players', 'events'])->latest()->paginate(20);
|
||||
|
||||
return view('admin.teams.index', compact('teams'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.teams.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'year_group' => ['nullable', 'string', 'max:20'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
Team::create($validated);
|
||||
|
||||
return redirect()->route('admin.teams.index')
|
||||
->with('success', __('admin.team_created'));
|
||||
}
|
||||
|
||||
public function edit(Team $team): View
|
||||
{
|
||||
$team->load([
|
||||
'coaches',
|
||||
'players' => fn ($q) => $q->orderBy('last_name'),
|
||||
'files.category',
|
||||
]);
|
||||
|
||||
$allCoaches = User::where('role', UserRole::Coach)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$parentReps = $team->parentReps();
|
||||
|
||||
$allTeams = Team::active()->orderBy('name')->get();
|
||||
|
||||
$fileCategories = FileCategory::active()->ordered()
|
||||
->with(['files' => fn ($q) => $q->latest()])
|
||||
->get();
|
||||
|
||||
return view('admin.teams.edit', compact(
|
||||
'team', 'allCoaches', 'parentReps', 'allTeams', 'fileCategories'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, Team $team): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'year_group' => ['nullable', 'string', 'max:20'],
|
||||
'is_active' => ['boolean'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
'coach_ids' => ['nullable', 'array'],
|
||||
'coach_ids.*' => ['integer', 'exists:users,id', function ($attr, $value, $fail) {
|
||||
$user = User::find($value);
|
||||
if (!$user || $user->role !== \App\Enums\UserRole::Coach) {
|
||||
$fail(__('validation.exists', ['attribute' => $attr]));
|
||||
}
|
||||
}],
|
||||
'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'],
|
||||
]);
|
||||
|
||||
$oldData = [
|
||||
'name' => $team->name,
|
||||
'year_group' => $team->year_group,
|
||||
'is_active' => $team->is_active,
|
||||
'notes' => $team->notes,
|
||||
];
|
||||
|
||||
$team->update([
|
||||
'name' => $request->input('name'),
|
||||
'year_group' => $request->input('year_group'),
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
'notes' => $request->input('notes'),
|
||||
]);
|
||||
|
||||
// Trainer-Zuordnung sync
|
||||
$coachIds = $request->input('coach_ids', []);
|
||||
$team->coaches()->sync(array_map('intval', $coachIds));
|
||||
|
||||
// Dateien sync
|
||||
$this->syncTeamFiles($team, $request);
|
||||
|
||||
$newData = [
|
||||
'name' => $team->name,
|
||||
'year_group' => $team->year_group,
|
||||
'is_active' => $team->is_active,
|
||||
'notes' => $team->notes,
|
||||
];
|
||||
ActivityLog::logWithChanges('updated', __('admin.log_team_updated', ['name' => $team->name]), 'Team', $team->id, $oldData, $newData);
|
||||
|
||||
return redirect()->route('admin.teams.edit', $team)
|
||||
->with('success', __('admin.team_updated'));
|
||||
}
|
||||
|
||||
public function updatePlayerTeam(Request $request, Team $team): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'player_id' => ['required', 'integer', 'exists:players,id'],
|
||||
'new_team_id' => ['required', 'integer', 'exists:teams,id'],
|
||||
]);
|
||||
|
||||
$player = Player::findOrFail($validated['player_id']);
|
||||
|
||||
// Spieler muss aktuell im Route-Team sein
|
||||
if ($player->team_id !== $team->id) {
|
||||
return response()->json(['error' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
// Ziel-Team muss existieren und aktiv sein
|
||||
$newTeam = Team::where('id', $validated['new_team_id'])->where('is_active', true)->first();
|
||||
if (!$newTeam) {
|
||||
return response()->json(['error' => 'Ziel-Team nicht gefunden oder inaktiv'], 422);
|
||||
}
|
||||
|
||||
$oldTeamId = $player->team_id;
|
||||
$player->update(['team_id' => $newTeam->id]);
|
||||
|
||||
ActivityLog::logWithChanges(
|
||||
'updated',
|
||||
__('admin.log_player_team_changed', ['name' => $player->full_name]),
|
||||
'Player',
|
||||
$player->id,
|
||||
['team_id' => $oldTeamId],
|
||||
['team_id' => (int) $validated['new_team_id']]
|
||||
);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
private function syncTeamFiles(Team $team, Request $request): void
|
||||
{
|
||||
$existingFileIds = $request->input('existing_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;
|
||||
}
|
||||
|
||||
$allFileIds = array_merge(
|
||||
array_map('intval', $existingFileIds),
|
||||
$newFileIds
|
||||
);
|
||||
$team->files()->sync($allFileIds);
|
||||
}
|
||||
}
|
||||
312
app/Http/Controllers/Admin/UserController.php
Executable file
312
app/Http/Controllers/Admin/UserController.php
Executable file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = User::with('children');
|
||||
|
||||
// Sortierung
|
||||
$sortable = ['name', 'email', 'role', 'last_login_at', 'is_active', 'created_at'];
|
||||
$sort = in_array($request->input('sort'), $sortable) ? $request->input('sort') : 'created_at';
|
||||
$direction = $request->input('direction') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
match ($sort) {
|
||||
'last_login_at' => $query->orderBy(DB::raw('CASE WHEN last_login_at IS NULL THEN 1 ELSE 0 END'))->orderBy('last_login_at', $direction),
|
||||
default => $query->orderBy($sort, $direction),
|
||||
};
|
||||
|
||||
$users = $query->paginate(20)->withQueryString();
|
||||
$trashedUsers = User::onlyTrashed()
|
||||
->where('deleted_at', '>=', now()->subDays(7))
|
||||
->latest('deleted_at')
|
||||
->get();
|
||||
|
||||
return view('admin.users.index', compact('users', 'trashedUsers', 'sort', 'direction'));
|
||||
}
|
||||
|
||||
public function edit(User $user): View
|
||||
{
|
||||
return view('admin.users.edit', compact('user'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schutz: Coach darf keine Admin-Konten aendern (S01).
|
||||
*/
|
||||
private function guardAgainstCoachModifyingAdmin(User $user): void
|
||||
{
|
||||
if (!auth()->user()->isAdmin() && $user->isAdmin()) {
|
||||
abort(403, __('admin.cannot_modify_admin'));
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'phone' => ['nullable', 'string', 'max:30'],
|
||||
'role' => ['required', 'in:admin,coach,parent_rep,user'],
|
||||
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
|
||||
]);
|
||||
|
||||
// Selbstschutz: Eigene Rolle nicht ändern
|
||||
if ($user->id === auth()->id()) {
|
||||
unset($validated['role']);
|
||||
} elseif (isset($validated['role']) && $validated['role'] === 'admin' && !auth()->user()->isAdmin()) {
|
||||
abort(403, __('admin.cannot_assign_admin_role'));
|
||||
}
|
||||
|
||||
// Handle profile picture upload
|
||||
if ($request->hasFile('profile_picture')) {
|
||||
if ($user->profile_picture) {
|
||||
Storage::disk('public')->delete($user->profile_picture);
|
||||
}
|
||||
$file = $request->file('profile_picture');
|
||||
$storedName = 'avatars/' . Str::uuid() . '.' . $file->guessExtension();
|
||||
Storage::disk('public')->putFileAs('', $file, $storedName);
|
||||
$validated['profile_picture'] = $storedName;
|
||||
} else {
|
||||
unset($validated['profile_picture']);
|
||||
}
|
||||
|
||||
$oldData = ['name' => $user->name, 'email' => $user->email, 'role' => $user->role->value];
|
||||
|
||||
// Rolle separat setzen (nicht in $fillable fuer Mass-Assignment-Schutz)
|
||||
$newRole = $validated['role'] ?? null;
|
||||
unset($validated['role']);
|
||||
$user->update($validated);
|
||||
if ($newRole) {
|
||||
$user->role = $newRole;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
$newData = ['name' => $user->name, 'email' => $user->email, 'role' => $user->role->value];
|
||||
ActivityLog::logWithChanges('updated', __('admin.log_user_updated', ['name' => $user->name]), 'User', $user->id, $oldData, $newData);
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', __('admin.user_updated'));
|
||||
}
|
||||
|
||||
public function toggleActive(User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', __('admin.cannot_deactivate_self'));
|
||||
}
|
||||
|
||||
$oldActive = $user->is_active;
|
||||
|
||||
$user->is_active = !$user->is_active;
|
||||
$user->save();
|
||||
|
||||
$status = $user->is_active ? __('admin.activated') : __('admin.deactivated');
|
||||
ActivityLog::logWithChanges('toggled_active', __('admin.log_user_toggled', ['name' => $user->name, 'status' => $status]), 'User', $user->id, ['is_active' => $oldActive], ['is_active' => $user->is_active]);
|
||||
|
||||
return back()->with('success', __('admin.user_toggled', ['status' => $status]));
|
||||
}
|
||||
|
||||
public function updateRole(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', __('admin.cannot_change_own_role'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'role' => ['required', 'in:admin,coach,parent_rep,user'],
|
||||
]);
|
||||
|
||||
if ($validated['role'] === 'admin' && !auth()->user()->isAdmin()) {
|
||||
abort(403, __('admin.cannot_assign_admin_role'));
|
||||
}
|
||||
|
||||
$oldRole = $user->role->value;
|
||||
|
||||
$user->role = $validated['role'];
|
||||
$user->save();
|
||||
|
||||
ActivityLog::logWithChanges('role_changed', __('admin.log_role_changed', ['name' => $user->name, 'role' => $validated['role']]), 'User', $user->id, ['role' => $oldRole], ['role' => $validated['role']]);
|
||||
|
||||
return back()->with('success', __('admin.role_updated'));
|
||||
}
|
||||
|
||||
public function resetPassword(User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', __('admin.cannot_reset_own_password'));
|
||||
}
|
||||
|
||||
$status = Password::sendResetLink(['email' => $user->email]);
|
||||
|
||||
ActivityLog::log('password_reset', __('admin.log_password_reset', ['name' => $user->name]), 'User', $user->id);
|
||||
|
||||
if ($status === Password::RESET_LINK_SENT) {
|
||||
return redirect()->route('admin.users.edit', $user)
|
||||
->with('success', __('admin.password_reset_link_sent'));
|
||||
}
|
||||
|
||||
return redirect()->route('admin.users.edit', $user)
|
||||
->with('error', __($status));
|
||||
}
|
||||
|
||||
public function removePicture(User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
if ($user->profile_picture) {
|
||||
Storage::disk('public')->delete($user->profile_picture);
|
||||
$user->update(['profile_picture' => null]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('admin.picture_removed'));
|
||||
}
|
||||
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
$this->guardAgainstCoachModifyingAdmin($user);
|
||||
|
||||
// Schutz: nicht sich selbst und nicht User ID 1 löschen
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', __('admin.cannot_delete_self'));
|
||||
}
|
||||
if ($user->id === 1) {
|
||||
return back()->with('error', __('admin.cannot_delete_main_admin'));
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
ActivityLog::logWithChanges('deleted', __('admin.log_user_deleted', ['name' => $user->name]), 'User', $user->id, ['name' => $user->name, 'email' => $user->email, 'role' => $user->role->value], null);
|
||||
|
||||
return redirect()->route('admin.users.index')->with('success', __('admin.user_deleted'));
|
||||
}
|
||||
|
||||
public function restore(int $id): RedirectResponse
|
||||
{
|
||||
$user = User::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if (! $user->isRestorable()) {
|
||||
return back()->with('error', __('admin.restore_expired'));
|
||||
}
|
||||
|
||||
$user->restore();
|
||||
|
||||
ActivityLog::logWithChanges('restored', __('admin.log_user_restored', ['name' => $user->name]), 'User', $user->id, null, ['name' => $user->name, 'email' => $user->email]);
|
||||
|
||||
return back()->with('success', __('admin.user_restored'));
|
||||
}
|
||||
|
||||
public function toggleDsgvoConsent(User $user): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->hasDsgvoConsent()) {
|
||||
return back()->with('error', __('admin.dsgvo_no_file'));
|
||||
}
|
||||
|
||||
if ($user->isDsgvoConfirmed()) {
|
||||
$old = [
|
||||
'dsgvo_accepted_at' => $user->dsgvo_accepted_at->toDateTimeString(),
|
||||
'dsgvo_accepted_by' => $user->dsgvoAcceptedBy?->name ?? (string) $user->dsgvo_accepted_by,
|
||||
];
|
||||
$user->dsgvo_accepted_at = null;
|
||||
$user->dsgvo_accepted_by = null;
|
||||
$user->save();
|
||||
ActivityLog::logWithChanges(
|
||||
'dsgvo_consent_revoked',
|
||||
__('admin.log_dsgvo_revoked', ['name' => $user->name]),
|
||||
'User', $user->id, $old, null
|
||||
);
|
||||
} else {
|
||||
$user->dsgvo_accepted_at = now();
|
||||
$user->dsgvo_accepted_by = auth()->id();
|
||||
$user->save();
|
||||
ActivityLog::logWithChanges(
|
||||
'dsgvo_consent_confirmed',
|
||||
__('admin.log_dsgvo_confirmed', ['name' => $user->name]),
|
||||
'User', $user->id, null,
|
||||
[
|
||||
'dsgvo_accepted_at' => now()->toDateTimeString(),
|
||||
'dsgvo_accepted_by' => auth()->user()->name,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return back()->with('success', __('admin.dsgvo_toggled'));
|
||||
}
|
||||
|
||||
public function rejectDsgvoConsent(User $user): RedirectResponse
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->hasDsgvoConsent()) {
|
||||
return back()->with('error', __('admin.dsgvo_no_file'));
|
||||
}
|
||||
|
||||
// Path-Traversal-Schutz
|
||||
if ($user->dsgvo_consent_file && !str_starts_with($user->dsgvo_consent_file, 'dsgvo/')) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Datei vom Disk löschen
|
||||
Storage::disk('local')->delete($user->dsgvo_consent_file);
|
||||
|
||||
$user->dsgvo_consent_file = null;
|
||||
$user->dsgvo_accepted_at = null;
|
||||
$user->dsgvo_accepted_by = null;
|
||||
$user->save();
|
||||
|
||||
ActivityLog::log(
|
||||
'dsgvo_consent_rejected',
|
||||
__('admin.log_dsgvo_rejected', ['name' => $user->name]),
|
||||
'User',
|
||||
$user->id
|
||||
);
|
||||
|
||||
return back()->with('success', __('admin.dsgvo_rejected'));
|
||||
}
|
||||
|
||||
public function viewDsgvoConsent(User $user)
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->dsgvo_consent_file || !str_starts_with($user->dsgvo_consent_file, 'dsgvo/') || !Storage::disk('local')->exists($user->dsgvo_consent_file)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
ActivityLog::log('dsgvo_document_viewed', __('admin.log_dsgvo_viewed', ['name' => $user->name]), 'User', $user->id);
|
||||
|
||||
$mimeType = Storage::disk('local')->mimeType($user->dsgvo_consent_file);
|
||||
|
||||
return response()->file(
|
||||
Storage::disk('local')->path($user->dsgvo_consent_file),
|
||||
['Content-Type' => $mimeType]
|
||||
);
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Auth/ForgotPasswordController.php
Normal file
62
app/Http/Controllers/Auth/ForgotPasswordController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
public function showForm(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
public function sendResetLink(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// Deaktivierte Benutzer: keinen Reset-Link senden, aber generische Meldung zurückgeben (V01)
|
||||
$user = User::where('email', $request->email)->first();
|
||||
if ($user && !$user->is_active) {
|
||||
return back()->with('status', __('passwords.sent'));
|
||||
}
|
||||
|
||||
try {
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
if ($status === Password::RESET_LINK_SENT) {
|
||||
ActivityLog::log('password_reset_requested', __('admin.log_password_reset_requested'));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Password reset mail failed', ['error' => $e->getMessage()]);
|
||||
|
||||
// Pruefen ob Mail ueberhaupt konfiguriert ist
|
||||
$mailer = config('mail.default');
|
||||
if ($mailer === 'smtp') {
|
||||
$hint = 'SMTP-Zugangsdaten in der .env-Datei pruefen (MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD).';
|
||||
} elseif ($mailer === 'log') {
|
||||
$hint = 'MAIL_MAILER=log — E-Mails werden nur ins Log geschrieben, nicht versendet. Fuer echten Versand SMTP konfigurieren.';
|
||||
} else {
|
||||
$hint = 'Mail-Konfiguration pruefen (MAIL_MAILER=' . $mailer . ').';
|
||||
}
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'E-Mail konnte nicht gesendet werden: ' . $e->getMessage() . ' — ' . $hint,
|
||||
]);
|
||||
}
|
||||
|
||||
// Immer dieselbe Erfolgsmeldung zurueckgeben (Email-Enumeration verhindern)
|
||||
return back()->with('status', __('passwords.sent'));
|
||||
}
|
||||
}
|
||||
84
app/Http/Controllers/Auth/LoginController.php
Executable file
84
app/Http/Controllers/Auth/LoginController.php
Executable file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function showForm()
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
// Honeypot — Bots füllen versteckte Felder aus
|
||||
if ($request->filled('website')) {
|
||||
ActivityLog::log('bot_blocked', 'Bot blocked on login (honeypot triggered)');
|
||||
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => __('auth_ui.login_failed')]);
|
||||
}
|
||||
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
// Deaktivierte Benutzer dürfen sich nicht einloggen (V01)
|
||||
$user = User::where('email', $request->email)->first();
|
||||
if ($user && !$user->is_active) {
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => __('auth_ui.login_failed')]);
|
||||
}
|
||||
|
||||
if (!Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
$maskedEmail = $this->maskEmail($request->email);
|
||||
ActivityLog::log('login_failed', __('admin.log_login_failed', ['email' => $maskedEmail]));
|
||||
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => __('auth_ui.login_failed')]);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
$request->user()->last_login_at = now();
|
||||
$request->user()->save();
|
||||
|
||||
ActivityLog::log('login', __('admin.log_login', ['name' => $request->user()->name]), 'User', $request->user()->id);
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
ActivityLog::log('logout', __('admin.log_logout', ['name' => $request->user()->name]), 'User', $request->user()->id);
|
||||
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
$parts = explode('@', $email, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
$local = $parts[0];
|
||||
$masked = mb_substr($local, 0, 2) . str_repeat('*', max(mb_strlen($local) - 2, 2));
|
||||
|
||||
return $masked . '@' . $parts[1];
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/Auth/RegisterController.php
Executable file
71
app/Http/Controllers/Auth/RegisterController.php
Executable file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Invitation;
|
||||
use App\Services\InvitationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
public function __construct(private InvitationService $invitationService) {}
|
||||
|
||||
public function showForm(string $token): View|RedirectResponse
|
||||
{
|
||||
$invitation = Invitation::with('players.team')->where('token', hash('sha256', $token))->first();
|
||||
|
||||
if (!$invitation || !$invitation->isValid()) {
|
||||
return redirect()->route('login')
|
||||
->with('error', __('auth_ui.invalid_invitation'));
|
||||
}
|
||||
|
||||
return view('auth.register', compact('invitation'));
|
||||
}
|
||||
|
||||
public function register(Request $request, string $token): RedirectResponse
|
||||
{
|
||||
$invitation = Invitation::with('players')->where('token', hash('sha256', $token))->first();
|
||||
|
||||
if (!$invitation || !$invitation->isValid()) {
|
||||
return redirect()->route('login')
|
||||
->with('error', __('auth_ui.invalid_invitation'));
|
||||
}
|
||||
|
||||
// Honeypot — Bots füllen versteckte Felder aus
|
||||
if ($request->filled('website')) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// E-Mail-Normalisierung vor Validierung (V17)
|
||||
$request->merge(['email' => strtolower(trim($request->input('email')))]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', Password::min(8)->letters()->numbers(), 'confirmed'],
|
||||
]);
|
||||
|
||||
// E-Mail muss mit Einladung übereinstimmen (falls eingeschränkt)
|
||||
if ($invitation->email && strtolower($validated['email']) !== strtolower($invitation->email)) {
|
||||
return back()->withInput()->withErrors([
|
||||
'email' => __('auth_ui.email_must_match_invitation', ['email' => $invitation->email]),
|
||||
]);
|
||||
}
|
||||
|
||||
$user = $this->invitationService->redeemInvitation($invitation, $validated);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
ActivityLog::log('registered', __('admin.log_registered', ['name' => $user->name]), 'User', $user->id);
|
||||
|
||||
return redirect()->route('dashboard')
|
||||
->with('success', __('auth_ui.welcome'));
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Auth/ResetPasswordController.php
Normal file
63
app/Http/Controllers/Auth/ResetPasswordController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
public function showResetForm(Request $request, string $token): View
|
||||
{
|
||||
return view('auth.reset-password', [
|
||||
'token' => $token,
|
||||
'email' => $request->query('email', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reset(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', PasswordRule::min(8)->letters()->numbers()],
|
||||
]);
|
||||
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user, string $password) {
|
||||
$user->forceFill([
|
||||
'password' => $password,
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
|
||||
ActivityLog::log(
|
||||
'password_changed',
|
||||
__('admin.log_password_changed_self', ['name' => $user->name]),
|
||||
'User',
|
||||
$user->id
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if ($status === Password::PASSWORD_RESET) {
|
||||
return redirect()->route('login')
|
||||
->with('status', __($status));
|
||||
}
|
||||
|
||||
// Generische Fehlermeldung — verhindert Email-Enumeration (T03)
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => __('passwords.token')]);
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/CateringController.php
Executable file
57
app/Http/Controllers/CateringController.php
Executable file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CateringStatus;
|
||||
use App\Enums\EventStatus;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCatering;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CateringController extends Controller
|
||||
{
|
||||
public function update(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($event->status === EventStatus::Cancelled) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$event->type->hasCatering()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
if ($event->status === EventStatus::Draft) {
|
||||
abort(403);
|
||||
}
|
||||
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'status' => 'required|in:yes,no,unknown',
|
||||
'note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$existing = EventCatering::where('event_id', $event->id)->where('user_id', auth()->id())->first();
|
||||
$oldStatus = $existing?->status?->value ?? 'unknown';
|
||||
|
||||
$catering = EventCatering::where('event_id', $event->id)->where('user_id', auth()->id())->first();
|
||||
if (!$catering) {
|
||||
$catering = new EventCatering(['event_id' => $event->id]);
|
||||
$catering->user_id = auth()->id();
|
||||
}
|
||||
$catering->status = CateringStatus::from($request->status);
|
||||
$catering->note = $request->note;
|
||||
$catering->save();
|
||||
|
||||
ActivityLog::logWithChanges('status_changed', __('admin.log_catering_changed', ['event' => $event->title, 'status' => $request->status]), 'Event', $event->id, ['status' => $oldStatus, 'user_id' => auth()->id(), 'source' => 'catering'], ['status' => $request->status, 'user_id' => auth()->id(), 'source' => 'catering']);
|
||||
|
||||
return redirect(route('events.show', $event) . '#catering');
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/CommentController.php
Executable file
42
app/Http/Controllers/CommentController.php
Executable file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function store(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($event->status === EventStatus::Cancelled) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
if ($event->status === EventStatus::Draft) {
|
||||
abort(403);
|
||||
}
|
||||
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'body' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$comment = $event->comments()->make(['body' => $request->body]);
|
||||
$comment->user_id = auth()->id();
|
||||
$comment->save();
|
||||
|
||||
ActivityLog::log('created', __('admin.log_comment_created', ['event' => $event->title]), 'Event', $event->id);
|
||||
|
||||
return redirect(route('events.show', $event) . '#comments');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Executable file
8
app/Http/Controllers/Controller.php
Executable file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
46
app/Http/Controllers/DashboardController.php
Executable file
46
app/Http/Controllers/DashboardController.php
Executable file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Models\Event;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$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'),
|
||||
])
|
||||
->published();
|
||||
|
||||
if (! $user->canAccessAdminPanel()) {
|
||||
$query->whereIn('team_id', $user->accessibleTeamIds());
|
||||
}
|
||||
|
||||
// Alle Events für den Kalender
|
||||
$calendarEvents = $query->orderBy('start_at')->get()
|
||||
->map(fn (Event $e) => [
|
||||
'id' => $e->id,
|
||||
'title' => $e->title,
|
||||
'type' => $e->type->value,
|
||||
'typeLabel' => $e->type->label(),
|
||||
'date' => $e->start_at->format('Y-m-d'),
|
||||
'time' => $e->start_at->format('H:i'),
|
||||
'team' => $e->team->name,
|
||||
'url' => route('events.show', $e),
|
||||
'tl' => [
|
||||
'y' => $e->participants->where('status', ParticipantStatus::Yes)->count(),
|
||||
'n' => $e->participants->where('status', ParticipantStatus::No)->count(),
|
||||
'o' => $e->participants->where('status', ParticipantStatus::Unknown)->count(),
|
||||
],
|
||||
'minMet' => $e->minimumsStatus(),
|
||||
]);
|
||||
|
||||
return view('dashboard', compact('calendarEvents'));
|
||||
}
|
||||
}
|
||||
128
app/Http/Controllers/EventController.php
Executable file
128
app/Http/Controllers/EventController.php
Executable file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventType;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$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'),
|
||||
]);
|
||||
|
||||
// Admins sehen auch Entwürfe, Eltern nur published
|
||||
if ($user->canAccessAdminPanel()) {
|
||||
$teams = Team::where('is_active', true)->orderBy('name')->get();
|
||||
} else {
|
||||
$teamIds = $user->accessibleTeamIds();
|
||||
$query->published()->whereIn('team_id', $teamIds);
|
||||
$teams = Team::whereIn('id', $teamIds)->orderBy('name')->get();
|
||||
}
|
||||
|
||||
// Filter: Team (nur Integer-IDs akzeptieren)
|
||||
if ($request->filled('team_id')) {
|
||||
$query->where('team_id', (int) $request->team_id);
|
||||
}
|
||||
|
||||
// Filter: Typ (nur gueltige EventType-Werte)
|
||||
if ($request->filled('type')) {
|
||||
$validTypes = array_column(EventType::cases(), 'value');
|
||||
if (in_array($request->type, $validTypes)) {
|
||||
$query->where('type', $request->type);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter: Zeitraum
|
||||
if ($request->input('period') === 'past') {
|
||||
$query->where('start_at', '<', now())->orderByDesc('start_at');
|
||||
} else {
|
||||
$query->where('start_at', '>=', now())->orderBy('start_at');
|
||||
}
|
||||
|
||||
$events = $query->paginate(15)->withQueryString();
|
||||
|
||||
return view('events.index', compact('events', 'teams'));
|
||||
}
|
||||
|
||||
public function show(Event $event): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Entwürfe nur für Admins
|
||||
if ($event->status === EventStatus::Draft && !$user->canAccessAdminPanel()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Kinder einmal laden, für Zugriffsprüfung + Teilnahme-Buttons
|
||||
$userChildren = $user->children()->select('players.id', 'players.team_id')->get();
|
||||
|
||||
// Zugriffsbeschraenkung: User muss Zugang zum Team haben (ueber accessibleTeamIds)
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
$accessibleTeamIds = $user->accessibleTeamIds();
|
||||
if (!$accessibleTeamIds->contains($event->team_id)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
$event->syncParticipants($user->id);
|
||||
|
||||
$isMeeting = $event->type === EventType::Meeting;
|
||||
|
||||
$relations = ['team', 'comments.user', 'files.category'];
|
||||
$relations[] = $isMeeting ? 'participants.user' : 'participants.player';
|
||||
$relations[] = 'participants.setByUser';
|
||||
if ($event->type->hasCatering()) {
|
||||
$relations[] = 'caterings.user';
|
||||
}
|
||||
if ($event->type->hasTimekeepers()) {
|
||||
$relations[] = 'timekeepers.user';
|
||||
}
|
||||
$event->load($relations);
|
||||
|
||||
$userChildIds = $userChildren->pluck('id');
|
||||
|
||||
// Eigener Catering-Status
|
||||
$myCatering = $event->type->hasCatering()
|
||||
? $event->caterings->where('user_id', $user->id)->first()
|
||||
: null;
|
||||
|
||||
// Eigener Zeitnehmer-Status
|
||||
$myTimekeeper = $event->type->hasTimekeepers()
|
||||
? $event->timekeepers->where('user_id', $user->id)->first()
|
||||
: null;
|
||||
|
||||
// Catering/Zeitnehmer-Verlauf für Staff (chronologische Statusänderungen)
|
||||
$cateringHistory = collect();
|
||||
$timekeeperHistory = collect();
|
||||
|
||||
if ($user->canAccessAdminPanel() && Setting::isFeatureVisibleFor('catering_history', $user)) {
|
||||
$statusLogs = ActivityLog::where('model_type', 'Event')
|
||||
->where('model_id', $event->id)
|
||||
->where('action', 'status_changed')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
$cateringHistory = $statusLogs->filter(
|
||||
fn ($log) => ($log->properties['new']['source'] ?? null) === 'catering'
|
||||
);
|
||||
$timekeeperHistory = $statusLogs->filter(
|
||||
fn ($log) => ($log->properties['new']['source'] ?? null) === 'timekeeper'
|
||||
);
|
||||
}
|
||||
|
||||
return view('events.show', compact('event', 'userChildIds', 'myCatering', 'myTimekeeper', 'cateringHistory', 'timekeeperHistory'));
|
||||
}
|
||||
}
|
||||
112
app/Http/Controllers/FileController.php
Normal file
112
app/Http/Controllers/FileController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\FileCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FileController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$categories = FileCategory::active()
|
||||
->ordered()
|
||||
->withCount('files')
|
||||
->get();
|
||||
|
||||
$activeCategory = $request->query('category');
|
||||
|
||||
$query = File::with(['category', 'uploader'])
|
||||
->whereHas('category', fn ($q) => $q->where('is_active', true))
|
||||
->latest();
|
||||
|
||||
if ($activeCategory) {
|
||||
$query->whereHas('category', fn ($q) => $q->where('slug', $activeCategory));
|
||||
}
|
||||
|
||||
// Nicht-Staff-Nutzer sehen nur Dateien ohne Team-Zuordnung oder aus eigenen Teams
|
||||
$user = auth()->user();
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
$userTeamIds = $user->accessibleTeamIds();
|
||||
$query->where(function ($q) use ($userTeamIds) {
|
||||
$q->whereDoesntHave('teams')
|
||||
->orWhereHas('teams', fn ($tq) => $tq->whereIn('teams.id', $userTeamIds));
|
||||
});
|
||||
}
|
||||
|
||||
$files = $query->paginate(25)->withQueryString();
|
||||
|
||||
return view('files.index', compact('categories', 'files', 'activeCategory'));
|
||||
}
|
||||
|
||||
public function download(File $file): StreamedResponse
|
||||
{
|
||||
$this->authorizeFileAccess($file);
|
||||
|
||||
$path = $file->getStoragePath();
|
||||
|
||||
if (!Storage::disk('local')->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return Storage::disk('local')->download($path, $file->original_name);
|
||||
}
|
||||
|
||||
public function preview(File $file)
|
||||
{
|
||||
$this->authorizeFileAccess($file);
|
||||
|
||||
if (!$file->isImage() && !$file->isPdf()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$path = $file->getStoragePath();
|
||||
|
||||
if (!Storage::disk('local')->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response()->file(
|
||||
Storage::disk('local')->path($path),
|
||||
['Content-Type' => $file->mime_type]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autorisierung: User muss Zugriff auf die Datei-Kategorie haben.
|
||||
* Admins/Coaches duerfen alles, Eltern nur Dateien aus aktiven Kategorien
|
||||
* die einem ihrer Teams zugeordnet sind oder allgemein verfuegbar sind.
|
||||
*/
|
||||
private function authorizeFileAccess(File $file): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Staff darf alles
|
||||
if ($user->canAccessAdminPanel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Datei muss zu einer aktiven Kategorie gehoeren
|
||||
if (!$file->category || !$file->category->is_active) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Pruefen ob Datei einem Team zugeordnet ist, auf das der User Zugriff hat
|
||||
$userTeamIds = $user->accessibleTeamIds();
|
||||
$fileTeamIds = $file->teams()->pluck('teams.id');
|
||||
|
||||
// Datei ohne Team-Zuordnung = allgemein verfuegbar
|
||||
if ($fileTeamIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mindestens ein Team muss uebereinstimmen
|
||||
if ($fileTeamIds->intersect($userTeamIds)->isEmpty()) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
}
|
||||
686
app/Http/Controllers/InstallerController.php
Normal file
686
app/Http/Controllers/InstallerController.php
Normal file
@@ -0,0 +1,686 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class InstallerController extends Controller
|
||||
{
|
||||
/**
|
||||
* Check if app is already installed.
|
||||
*/
|
||||
public static function isInstalled(): bool
|
||||
{
|
||||
return file_exists(storage_path('installed'));
|
||||
}
|
||||
|
||||
// ─── Step 1: System Requirements ───────────────────────
|
||||
|
||||
public function requirements()
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$checks = $this->runRequirementChecks();
|
||||
|
||||
return view('installer.steps.requirements', [
|
||||
'currentStep' => 1,
|
||||
'checks' => $checks,
|
||||
'allPassed' => collect($checks)->where('required', true)->every(fn ($c) => $c['passed']),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Step 2: Database ──────────────────────────────────
|
||||
|
||||
public function database()
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
return view('installer.steps.database', [
|
||||
'currentStep' => 2,
|
||||
'dbDriver' => old('db_driver', session('installer.db_driver', 'sqlite')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeDatabase(Request $request)
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$driver = $request->input('db_driver', 'sqlite');
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
$request->validate([
|
||||
'db_host' => 'required|string',
|
||||
'db_port' => 'required|integer|min:1|max:65535',
|
||||
'db_database' => 'required|string',
|
||||
'db_username' => 'required|string',
|
||||
'db_password' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Test MySQL connection before writing config
|
||||
$testResult = $this->testMysqlConnection(
|
||||
$request->input('db_host'),
|
||||
(int) $request->input('db_port'),
|
||||
$request->input('db_database'),
|
||||
$request->input('db_username'),
|
||||
$request->input('db_password', ''),
|
||||
);
|
||||
|
||||
if ($testResult !== true) {
|
||||
Log::error('Installer: DB connection failed', ['error' => $testResult]);
|
||||
return back()->withInput()
|
||||
->with('error', 'Datenbankverbindung fehlgeschlagen. Bitte Zugangsdaten pruefen.');
|
||||
}
|
||||
}
|
||||
|
||||
// Write DB config to .env
|
||||
$this->updateEnvValues($this->buildDbEnvValues($driver, $request));
|
||||
|
||||
// For SQLite: ensure database file exists with secure permissions
|
||||
if ($driver === 'sqlite') {
|
||||
$dbPath = database_path('database.sqlite');
|
||||
if (! file_exists($dbPath)) {
|
||||
touch($dbPath);
|
||||
}
|
||||
chmod($dbPath, 0640);
|
||||
}
|
||||
|
||||
// Clear config cache so new .env values take effect
|
||||
Artisan::call('config:clear');
|
||||
|
||||
// Set the runtime DB config for this request (since .env was just written)
|
||||
if ($driver === 'sqlite') {
|
||||
config([
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => database_path('database.sqlite'),
|
||||
]);
|
||||
} else {
|
||||
config([
|
||||
'database.default' => 'mysql',
|
||||
'database.connections.mysql.host' => $request->input('db_host', '127.0.0.1'),
|
||||
'database.connections.mysql.port' => $request->input('db_port', '3306'),
|
||||
'database.connections.mysql.database' => $request->input('db_database'),
|
||||
'database.connections.mysql.username' => $request->input('db_username'),
|
||||
'database.connections.mysql.password' => $request->input('db_password', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
try {
|
||||
Artisan::call('migrate', ['--force' => true]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Installer: Migration failed', ['error' => $e->getMessage()]);
|
||||
return back()->withInput()
|
||||
->with('error', 'Migration fehlgeschlagen. Details im Laravel-Log.');
|
||||
}
|
||||
|
||||
// Generate APP_KEY now (modifies .env — must happen before finalize)
|
||||
if (empty(config('app.key')) || config('app.key') === 'base64:') {
|
||||
Artisan::call('key:generate', ['--force' => true]);
|
||||
}
|
||||
|
||||
// Store state in session
|
||||
session(['installer.db_driver' => $driver]);
|
||||
session(['installer.db_configured' => true]);
|
||||
|
||||
return redirect()->route('install.app');
|
||||
}
|
||||
|
||||
// ─── Step 3: App Configuration ─────────────────────────
|
||||
|
||||
public function app()
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
if (! session('installer.db_configured')) {
|
||||
return redirect()->route('install.database')
|
||||
->with('error', 'Bitte zuerst die Datenbank konfigurieren.');
|
||||
}
|
||||
|
||||
return view('installer.steps.app', [
|
||||
'currentStep' => 3,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeApp(Request $request)
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'app_name' => 'required|string|max:100',
|
||||
'app_slogan' => 'nullable|string|max:255',
|
||||
'app_url' => 'required|url',
|
||||
'admin_name' => 'required|string|max:255',
|
||||
'admin_email' => 'required|email|max:255',
|
||||
'admin_password' => ['required', 'string', \Illuminate\Validation\Rules\Password::min(8)->letters()->numbers(), 'confirmed'],
|
||||
]);
|
||||
|
||||
// Write APP_NAME + APP_URL to .env now (triggers dev-server restart —
|
||||
// safe here because we redirect immediately after)
|
||||
$appName = $request->input('app_name');
|
||||
$this->updateEnvValues([
|
||||
'APP_NAME' => '"' . str_replace('"', '\\"', $appName) . '"',
|
||||
'APP_URL' => $request->input('app_url'),
|
||||
]);
|
||||
|
||||
session([
|
||||
'installer.app_name' => $appName,
|
||||
'installer.app_slogan' => $request->input('app_slogan'),
|
||||
'installer.app_url' => $request->input('app_url'),
|
||||
'installer.admin_name' => $request->input('admin_name'),
|
||||
'installer.admin_email' => $request->input('admin_email'),
|
||||
// Passwort sofort hashen (nicht Klartext in Session speichern).
|
||||
// Der 'hashed' Cast im User-Model erkennt via Hash::isHashed()
|
||||
// dass der Wert bereits gehasht ist und hasht NICHT doppelt.
|
||||
'installer.admin_password_hash' => Hash::make($request->input('admin_password')),
|
||||
'installer.app_configured' => true,
|
||||
]);
|
||||
|
||||
return redirect()->route('install.mail');
|
||||
}
|
||||
|
||||
// ─── Step 4: E-Mail Configuration ───────────────────────
|
||||
|
||||
public function mail()
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
if (! session('installer.app_configured')) {
|
||||
return redirect()->route('install.app')
|
||||
->with('error', 'Bitte zuerst die App-Einstellungen konfigurieren.');
|
||||
}
|
||||
|
||||
$defaults = $this->getDefaultPasswordResetTexts();
|
||||
|
||||
return view('installer.steps.mail', [
|
||||
'currentStep' => 4,
|
||||
'defaultPwResetDe' => $defaults['de'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeMail(Request $request)
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$mailMode = $request->input('mail_mode', 'log');
|
||||
|
||||
if ($mailMode === 'smtp') {
|
||||
$request->validate([
|
||||
'mail_host' => 'required|string|max:255',
|
||||
'mail_port' => 'required|integer|min:1|max:65535',
|
||||
'mail_username' => 'required|string|max:255',
|
||||
'mail_password' => 'required|string|max:255',
|
||||
'mail_from_address' => 'required|email|max:255',
|
||||
'mail_from_name' => 'nullable|string|max:255',
|
||||
'mail_encryption' => 'required|in:tls,ssl,none',
|
||||
]);
|
||||
|
||||
session([
|
||||
'installer.mail_mode' => 'smtp',
|
||||
'installer.mail_host' => $request->input('mail_host'),
|
||||
'installer.mail_port' => $request->input('mail_port'),
|
||||
'installer.mail_username' => $request->input('mail_username'),
|
||||
'installer.mail_password' => $request->input('mail_password'),
|
||||
'installer.mail_from_address' => $request->input('mail_from_address'),
|
||||
'installer.mail_from_name' => $request->input('mail_from_name', ''),
|
||||
'installer.mail_encryption' => $request->input('mail_encryption'),
|
||||
]);
|
||||
} else {
|
||||
session(['installer.mail_mode' => 'log']);
|
||||
}
|
||||
|
||||
session([
|
||||
'installer.password_reset_email_de' => $request->input('password_reset_email_de', ''),
|
||||
'installer.mail_configured' => true,
|
||||
]);
|
||||
|
||||
return redirect()->route('install.finalize');
|
||||
}
|
||||
|
||||
public function testMail(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return response()->json(['success' => false, 'message' => 'Bereits installiert.']);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'mail_host' => 'required|string|max:255',
|
||||
'mail_port' => 'required|integer|min:1|max:65535',
|
||||
'mail_username' => 'required|string|max:255',
|
||||
'mail_password' => 'required|string|max:255',
|
||||
'mail_encryption' => 'required|in:tls,ssl,none',
|
||||
]);
|
||||
|
||||
try {
|
||||
$encryption = $request->input('mail_encryption');
|
||||
$tls = ($encryption !== 'none');
|
||||
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
|
||||
$request->input('mail_host'),
|
||||
(int) $request->input('mail_port'),
|
||||
$tls,
|
||||
);
|
||||
$transport->setUsername($request->input('mail_username'));
|
||||
$transport->setPassword($request->input('mail_password'));
|
||||
$transport->start();
|
||||
$transport->stop();
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'SMTP-Verbindung erfolgreich!']);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 5: Finalize ──────────────────────────────────
|
||||
|
||||
public function finalize()
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
if (! session('installer.mail_configured')) {
|
||||
return redirect()->route('install.mail')
|
||||
->with('error', 'Bitte zuerst die E-Mail-Einstellungen konfigurieren.');
|
||||
}
|
||||
|
||||
return view('installer.steps.finalize', [
|
||||
'currentStep' => 5,
|
||||
'appName' => session('installer.app_name'),
|
||||
'appSlogan' => session('installer.app_slogan'),
|
||||
'adminEmail' => session('installer.admin_email'),
|
||||
'adminName' => session('installer.admin_name'),
|
||||
'dbDriver' => session('installer.db_driver', 'sqlite'),
|
||||
'installed' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeFinalize(Request $request)
|
||||
{
|
||||
if (self::isInstalled()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$installDemo = $request->boolean('install_demo');
|
||||
|
||||
// Pruefen ob alle Session-Daten vorhanden sind
|
||||
$requiredSessionKeys = [
|
||||
'installer.admin_email', 'installer.admin_name',
|
||||
'installer.admin_password_hash', 'installer.app_name',
|
||||
];
|
||||
foreach ($requiredSessionKeys as $key) {
|
||||
if (empty(session($key))) {
|
||||
return back()->with('error', "Session-Daten verloren ('{$key}' fehlt). Bitte die Installation erneut ab Schritt 2 durchfuehren.");
|
||||
}
|
||||
}
|
||||
|
||||
// Datenbankverbindung sicherstellen (wurde in Schritt 2 konfiguriert via .env)
|
||||
try {
|
||||
\Illuminate\Support\Facades\DB::connection()->getPdo();
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Datenbankverbindung fehlgeschlagen: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
$appName = session('installer.app_name');
|
||||
|
||||
// 1. Create admin user (guaranteed ID 1 on fresh DB)
|
||||
$admin = User::updateOrCreate(
|
||||
['email' => session('installer.admin_email')],
|
||||
[
|
||||
'name' => session('installer.admin_name'),
|
||||
'password' => session('installer.admin_password_hash'),
|
||||
]
|
||||
);
|
||||
$admin->is_active = true;
|
||||
$admin->role = UserRole::Admin;
|
||||
$admin->save();
|
||||
|
||||
// 2. Run required seeders (Settings + FileCategories)
|
||||
Artisan::call('db:seed', [
|
||||
'--class' => 'Database\\Seeders\\SettingsSeeder',
|
||||
'--force' => true,
|
||||
]);
|
||||
Artisan::call('db:seed', [
|
||||
'--class' => 'Database\\Seeders\\FileCategorySeeder',
|
||||
'--force' => true,
|
||||
]);
|
||||
|
||||
// 3. Override settings with installer values
|
||||
Setting::set('app_name', $appName);
|
||||
$slogan = session('installer.app_slogan');
|
||||
if ($slogan) {
|
||||
Setting::set('app_slogan', '<p><em>' . e($slogan) . '</em></p>');
|
||||
}
|
||||
|
||||
// 4. Mail-Konfiguration in .env schreiben
|
||||
$mailMode = session('installer.mail_mode', 'log');
|
||||
if ($mailMode === 'smtp') {
|
||||
$mailEncryption = session('installer.mail_encryption', 'tls');
|
||||
$this->updateEnvValues([
|
||||
'MAIL_MAILER' => 'smtp',
|
||||
'MAIL_HOST' => session('installer.mail_host'),
|
||||
'MAIL_PORT' => session('installer.mail_port'),
|
||||
'MAIL_USERNAME' => session('installer.mail_username'),
|
||||
'MAIL_PASSWORD' => session('installer.mail_password'),
|
||||
'MAIL_FROM_ADDRESS' => session('installer.mail_from_address'),
|
||||
'MAIL_FROM_NAME' => session('installer.mail_from_name', $appName),
|
||||
'MAIL_SCHEME' => $mailEncryption === 'none' ? '' : $mailEncryption,
|
||||
]);
|
||||
} else {
|
||||
$this->updateEnvValues([
|
||||
'MAIL_MAILER' => 'log',
|
||||
]);
|
||||
}
|
||||
|
||||
// 5. Passwort-Reset E-Mail-Texte setzen
|
||||
$customDe = session('installer.password_reset_email_de', '');
|
||||
$defaults = $this->getDefaultPasswordResetTexts();
|
||||
|
||||
// DE: Benutzer-Text aus Installer oder Default
|
||||
$deText = (strip_tags($customDe) !== '') ? $customDe : $defaults['de'];
|
||||
Setting::set('password_reset_email_de', $deText);
|
||||
|
||||
// Andere Sprachen: Default-Texte setzen
|
||||
foreach (['en', 'pl', 'ru', 'ar', 'tr'] as $locale) {
|
||||
Setting::set('password_reset_email_' . $locale, $defaults[$locale]);
|
||||
}
|
||||
|
||||
// 6. Optionally run DemoDataSeeder
|
||||
if ($installDemo) {
|
||||
Artisan::call('db:seed', [
|
||||
'--class' => 'Database\\Seeders\\DemoDataSeeder',
|
||||
'--force' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// 7. Create storage symlink
|
||||
try {
|
||||
Artisan::call('storage:link');
|
||||
} catch (\Exception $e) {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// 8. Clear all caches
|
||||
Artisan::call('config:clear');
|
||||
Artisan::call('cache:clear');
|
||||
Artisan::call('view:clear');
|
||||
Artisan::call('route:clear');
|
||||
try {
|
||||
Setting::clearCache();
|
||||
} catch (\Exception $e) {
|
||||
// Cache may already be cleared
|
||||
}
|
||||
|
||||
// 9. Mark as installed
|
||||
$installedPath = storage_path('installed');
|
||||
file_put_contents($installedPath, json_encode([
|
||||
'installed_at' => now()->toIso8601String(),
|
||||
'version' => config('app.version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'db_driver' => session('installer.db_driver', 'sqlite'),
|
||||
], JSON_PRETTY_PRINT));
|
||||
chmod($installedPath, 0600);
|
||||
|
||||
// 9b. Opt-in registration with support backend
|
||||
if ($request->boolean('register_installation')) {
|
||||
try {
|
||||
$supportService = app(\App\Services\SupportApiService::class);
|
||||
$supportService->register([
|
||||
'app_name' => $appName,
|
||||
'app_url' => session('installer.app_url'),
|
||||
'app_version' => config('app.version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'db_driver' => session('installer.db_driver', 'sqlite'),
|
||||
'installed_at' => now()->toIso8601String(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Installation registration failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 9c. Store license key if provided
|
||||
$licenseKey = $request->input('license_key');
|
||||
if ($licenseKey) {
|
||||
Setting::set('license_key', trim($licenseKey));
|
||||
}
|
||||
|
||||
// 10. Store completion info in session, then clean up installer data
|
||||
$completionData = [
|
||||
'installed' => true,
|
||||
'install_demo' => $installDemo,
|
||||
'admin_email' => session('installer.admin_email'),
|
||||
'admin_name' => session('installer.admin_name'),
|
||||
];
|
||||
|
||||
session()->forget([
|
||||
'installer.db_driver', 'installer.db_configured',
|
||||
'installer.app_name', 'installer.app_slogan',
|
||||
'installer.app_url', 'installer.admin_name',
|
||||
'installer.admin_email', 'installer.admin_password_hash',
|
||||
'installer.app_configured',
|
||||
'installer.mail_mode', 'installer.mail_host',
|
||||
'installer.mail_port', 'installer.mail_username',
|
||||
'installer.mail_password', 'installer.mail_from_address',
|
||||
'installer.mail_from_name', 'installer.mail_encryption',
|
||||
'installer.password_reset_email_de', 'installer.mail_configured',
|
||||
]);
|
||||
|
||||
session(['installer.completed' => $completionData]);
|
||||
|
||||
return redirect()->route('install.complete');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Installer: Installation failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'file' => $e->getFile() . ':' . $e->getLine(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Waehrend der Installation ist kein Laravel-Log per FTP leicht zugaenglich.
|
||||
// Daher zeigen wir die Fehlermeldung direkt an.
|
||||
$errorDetail = $e->getMessage();
|
||||
$errorFile = basename($e->getFile()) . ':' . $e->getLine();
|
||||
|
||||
return back()->with('error', "Installation fehlgeschlagen: {$errorDetail} (in {$errorFile})");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Completion Page ───────────────────────────────────
|
||||
|
||||
public function complete()
|
||||
{
|
||||
$data = session('installer.completed');
|
||||
|
||||
if (! $data) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
// Clear the completion data so this page can't be revisited
|
||||
session()->forget('installer.completed');
|
||||
|
||||
return view('installer.steps.finalize', [
|
||||
'currentStep' => 5,
|
||||
'installed' => true,
|
||||
'installDemo' => $data['install_demo'] ?? false,
|
||||
'adminEmail' => $data['admin_email'] ?? '',
|
||||
'adminName' => $data['admin_name'] ?? '',
|
||||
'appName' => null,
|
||||
'dbDriver' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Private Helpers ───────────────────────────────────
|
||||
|
||||
private function runRequirementChecks(): array
|
||||
{
|
||||
$checks = [];
|
||||
|
||||
// PHP version
|
||||
$checks[] = [
|
||||
'name' => 'PHP Version >= 8.2',
|
||||
'current' => PHP_VERSION,
|
||||
'passed' => version_compare(PHP_VERSION, '8.2.0', '>='),
|
||||
'required' => true,
|
||||
];
|
||||
|
||||
// Required PHP extensions
|
||||
foreach (['pdo', 'pdo_sqlite', 'mbstring', 'openssl', 'tokenizer', 'xml', 'ctype', 'fileinfo', 'dom'] as $ext) {
|
||||
$checks[] = [
|
||||
'name' => "PHP Extension: {$ext}",
|
||||
'current' => extension_loaded($ext) ? 'Geladen' : 'Fehlt',
|
||||
'passed' => extension_loaded($ext),
|
||||
'required' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Optional: pdo_mysql
|
||||
$checks[] = [
|
||||
'name' => 'PHP Extension: pdo_mysql (nur für MySQL)',
|
||||
'current' => extension_loaded('pdo_mysql') ? 'Geladen' : 'Fehlt',
|
||||
'passed' => extension_loaded('pdo_mysql'),
|
||||
'required' => false,
|
||||
];
|
||||
|
||||
// Directory permissions
|
||||
$dirs = [
|
||||
'storage/' => storage_path(),
|
||||
'storage/app/' => storage_path('app'),
|
||||
'storage/framework/cache/' => storage_path('framework/cache'),
|
||||
'storage/framework/sessions/' => storage_path('framework/sessions'),
|
||||
'storage/framework/views/' => storage_path('framework/views'),
|
||||
'storage/logs/' => storage_path('logs'),
|
||||
'bootstrap/cache/' => base_path('bootstrap/cache'),
|
||||
'database/' => database_path(),
|
||||
];
|
||||
foreach ($dirs as $label => $path) {
|
||||
$checks[] = [
|
||||
'name' => "Schreibberechtigung: {$label}",
|
||||
'current' => is_writable($path) ? 'Schreibbar' : 'Nicht schreibbar',
|
||||
'passed' => is_writable($path),
|
||||
'required' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// .env file
|
||||
$checks[] = [
|
||||
'name' => '.env Datei',
|
||||
'current' => file_exists(base_path('.env')) ? 'Vorhanden' : 'Fehlt',
|
||||
'passed' => file_exists(base_path('.env')),
|
||||
'required' => true,
|
||||
];
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
private function testMysqlConnection(string $host, int $port, string $database, string $username, string $password): true|string
|
||||
{
|
||||
try {
|
||||
new \PDO(
|
||||
"mysql:host={$host};port={$port};dbname={$database}",
|
||||
$username,
|
||||
$password,
|
||||
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_TIMEOUT => 5]
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (\PDOException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private function buildDbEnvValues(string $driver, Request $request): array
|
||||
{
|
||||
if ($driver === 'sqlite') {
|
||||
return [
|
||||
'DB_CONNECTION' => 'sqlite',
|
||||
'DB_HOST' => '',
|
||||
'DB_PORT' => '',
|
||||
'DB_DATABASE' => '',
|
||||
'DB_USERNAME' => '',
|
||||
'DB_PASSWORD' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$password = $request->input('db_password', '');
|
||||
|
||||
return [
|
||||
'DB_CONNECTION' => 'mysql',
|
||||
'DB_HOST' => $request->input('db_host', '127.0.0.1'),
|
||||
'DB_PORT' => $request->input('db_port', '3306'),
|
||||
'DB_DATABASE' => $request->input('db_database'),
|
||||
'DB_USERNAME' => $request->input('db_username'),
|
||||
'DB_PASSWORD' => $password !== '' ? '"' . str_replace('"', '\\"', $password) . '"' : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function getDefaultPasswordResetTexts(): array
|
||||
{
|
||||
return [
|
||||
'de' => '<p>Hallo {name},</p><p>du hast eine Passwort-Zuruecksetzung fuer dein Konto bei {app_name} angefordert. Klicke auf den Button unten, um ein neues Passwort zu vergeben.</p><p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>',
|
||||
'en' => '<p>Hello {name},</p><p>You requested a password reset for your account at {app_name}. Click the button below to set a new password.</p><p>If you did not request this, you can safely ignore this email.</p>',
|
||||
'pl' => '<p>Witaj {name},</p><p>Otrzymalismy prosbe o zresetowanie hasla do Twojego konta w {app_name}. Kliknij przycisk ponizej, aby ustawic nowe haslo.</p><p>Jesli nie prosiles o zmiane hasla, zignoruj te wiadomosc.</p>',
|
||||
'ru' => '<p>Здравствуйте {name},</p><p>Вы запросили сброс пароля для вашей учетной записи в {app_name}. Нажмите кнопку ниже, чтобы установить новый пароль.</p><p>Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо.</p>',
|
||||
'ar' => '<p>مرحباً {name}،</p><p>لقد تلقينا طلباً لإعادة تعيين كلمة المرور لحسابك في {app_name}. انقر على الزر أدناه لتعيين كلمة مرور جديدة.</p><p>إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني.</p>',
|
||||
'tr' => '<p>Merhaba {name},</p><p>{app_name} hesabiniz icin sifre sifirlama talebinde bulundunuz. Yeni bir sifre belirlemek icin asagidaki butona tiklayin.</p><p>Bu talebi siz yapmadiysan, bu e-postayi goerurmezden gelebilirsiniz.</p>',
|
||||
];
|
||||
}
|
||||
|
||||
private function updateEnvValues(array $values): void
|
||||
{
|
||||
$envPath = base_path('.env');
|
||||
$envContent = file_get_contents($envPath);
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
// Empty values: comment out the line
|
||||
if ($value === '' || $value === null) {
|
||||
$pattern = "/^{$key}=.*/m";
|
||||
if (preg_match($pattern, $envContent)) {
|
||||
$envContent = preg_replace($pattern, "# {$key}=", $envContent);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Newline-Injection verhindern und Werte quoten (T07)
|
||||
$value = str_replace(["\n", "\r", "\0"], '', $value);
|
||||
if (!preg_match('/^".*"$/', $value)) {
|
||||
$value = '"' . str_replace('"', '\\"', $value) . '"';
|
||||
}
|
||||
|
||||
$replacement = "{$key}={$value}";
|
||||
$pattern = "/^{$key}=.*/m";
|
||||
|
||||
if (preg_match($pattern, $envContent)) {
|
||||
$envContent = preg_replace($pattern, $replacement, $envContent);
|
||||
} else {
|
||||
// Also check for commented-out version
|
||||
$commentPattern = "/^#\s*{$key}=.*/m";
|
||||
if (preg_match($commentPattern, $envContent)) {
|
||||
$envContent = preg_replace($commentPattern, $replacement, $envContent);
|
||||
} else {
|
||||
$envContent .= "\n{$replacement}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($envPath, $envContent);
|
||||
}
|
||||
}
|
||||
109
app/Http/Controllers/ParticipantController.php
Executable file
109
app/Http/Controllers/ParticipantController.php
Executable file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventType;
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventParticipant;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ParticipantController extends Controller
|
||||
{
|
||||
public function update(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($event->status === EventStatus::Cancelled) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->canAccessAdminPanel() && $event->status === EventStatus::Draft) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Team-Zugriffspruefung: User muss Zugang zum Event-Team haben
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
if ($event->type === EventType::Meeting) {
|
||||
return $this->updateMeetingParticipant($request, $event);
|
||||
}
|
||||
|
||||
return $this->updatePlayerParticipant($request, $event);
|
||||
}
|
||||
|
||||
private function updatePlayerParticipant(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$request->validate([
|
||||
'player_id' => 'required|integer',
|
||||
'status' => 'required|in:yes,no,unknown',
|
||||
]);
|
||||
|
||||
$participant = EventParticipant::where('event_id', $event->id)
|
||||
->where('player_id', $request->player_id)
|
||||
->firstOrFail();
|
||||
|
||||
// Policy-Check: nur eigene Kinder oder Admin
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
$isParent = DB::table('parent_player')
|
||||
->where('parent_id', $user->id)
|
||||
->where('player_id', $request->player_id)
|
||||
->exists();
|
||||
|
||||
if (!$isParent) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
$oldStatus = $participant->status->value;
|
||||
|
||||
$participant->status = ParticipantStatus::from($request->status);
|
||||
$participant->set_by_user_id = $user->id;
|
||||
$participant->responded_at = now();
|
||||
$participant->save();
|
||||
|
||||
ActivityLog::logWithChanges('participant_status_changed', __('admin.log_participant_changed', ['event' => $event->title, 'status' => $request->status]), 'Event', $event->id, ['status' => $oldStatus, 'player' => $participant->player?->full_name ?? ''], ['status' => $request->status]);
|
||||
|
||||
return redirect(route('events.show', $event) . '#participants');
|
||||
}
|
||||
|
||||
private function updateMeetingParticipant(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$request->validate([
|
||||
'user_id' => 'required|integer',
|
||||
'status' => 'required|in:yes,no,unknown',
|
||||
]);
|
||||
|
||||
$participant = EventParticipant::where('event_id', $event->id)
|
||||
->where('user_id', $request->user_id)
|
||||
->firstOrFail();
|
||||
|
||||
// Policy-Check: nur eigener Eintrag oder Admin
|
||||
if (!$user->canAccessAdminPanel() && (int) $participant->user_id !== $user->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$oldStatus = $participant->status->value;
|
||||
|
||||
$participant->status = ParticipantStatus::from($request->status);
|
||||
$participant->set_by_user_id = $user->id;
|
||||
$participant->responded_at = now();
|
||||
$participant->save();
|
||||
|
||||
ActivityLog::logWithChanges('participant_status_changed', __('admin.log_participant_changed', ['event' => $event->title, 'status' => $request->status]), 'Event', $event->id, ['status' => $oldStatus, 'player' => $participant->user?->name ?? ''], ['status' => $request->status]);
|
||||
|
||||
return redirect(route('events.show', $event) . '#participants');
|
||||
}
|
||||
}
|
||||
207
app/Http/Controllers/ProfileController.php
Executable file
207
app/Http/Controllers/ProfileController.php
Executable file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\ActivityLog;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
public function edit(): View
|
||||
{
|
||||
$user = auth()->user();
|
||||
$user->load('children.team');
|
||||
|
||||
return view('profile.edit', compact('user'));
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$supported = \App\Http\Middleware\SetLocaleMiddleware::supportedLocales();
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'phone' => ['nullable', 'string', 'max:30'],
|
||||
'locale' => ['nullable', 'string', 'in:' . implode(',', $supported)],
|
||||
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Handle profile picture upload
|
||||
if ($request->hasFile('profile_picture')) {
|
||||
// Delete old picture
|
||||
if ($user->profile_picture) {
|
||||
Storage::disk('public')->delete($user->profile_picture);
|
||||
}
|
||||
|
||||
$file = $request->file('profile_picture');
|
||||
$storedName = 'avatars/' . Str::uuid() . '.' . $file->guessExtension();
|
||||
Storage::disk('public')->putFileAs('', $file, $storedName);
|
||||
|
||||
$user->profile_picture = $storedName;
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'name' => $request->name,
|
||||
'phone' => $request->input('phone'),
|
||||
'locale' => $request->input('locale', 'de'),
|
||||
'profile_picture' => $user->profile_picture,
|
||||
]);
|
||||
|
||||
return back()->with('success', __('profile.updated'));
|
||||
}
|
||||
|
||||
public function removePicture(): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user->profile_picture) {
|
||||
Storage::disk('public')->delete($user->profile_picture);
|
||||
$user->update(['profile_picture' => null]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('admin.picture_removed'));
|
||||
}
|
||||
|
||||
public function uploadDsgvoConsent(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'dsgvo_consent_file' => ['required', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png,gif,webp'],
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Alte Datei löschen falls vorhanden
|
||||
if ($user->dsgvo_consent_file) {
|
||||
Storage::disk('local')->delete($user->dsgvo_consent_file);
|
||||
}
|
||||
|
||||
$file = $request->file('dsgvo_consent_file');
|
||||
$storedName = 'dsgvo/' . Str::uuid() . '.' . $file->guessExtension();
|
||||
Storage::disk('local')->putFileAs('', $file, $storedName);
|
||||
|
||||
// Bei Re-Upload: Bestätigung zurücksetzen
|
||||
$user->dsgvo_consent_file = $storedName;
|
||||
$user->dsgvo_accepted_at = null;
|
||||
$user->dsgvo_accepted_by = null;
|
||||
$user->save();
|
||||
|
||||
ActivityLog::log(
|
||||
'dsgvo_consent_uploaded',
|
||||
__('admin.log_dsgvo_consent_uploaded', ['name' => $user->name]),
|
||||
'User',
|
||||
$user->id
|
||||
);
|
||||
|
||||
return back()->with('success', __('profile.dsgvo_uploaded'));
|
||||
}
|
||||
|
||||
public function removeDsgvoConsent(): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user->dsgvo_consent_file) {
|
||||
Storage::disk('local')->delete($user->dsgvo_consent_file);
|
||||
$user->dsgvo_consent_file = null;
|
||||
$user->dsgvo_accepted_at = null;
|
||||
$user->dsgvo_accepted_by = null;
|
||||
$user->save();
|
||||
|
||||
ActivityLog::log(
|
||||
'dsgvo_consent_removed',
|
||||
__('admin.log_dsgvo_consent_removed', ['name' => $user->name]),
|
||||
'User',
|
||||
$user->id
|
||||
);
|
||||
}
|
||||
|
||||
return back()->with('success', __('profile.dsgvo_removed'));
|
||||
}
|
||||
|
||||
public function downloadDsgvoConsent(): BinaryFileResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->dsgvo_consent_file || !str_starts_with($user->dsgvo_consent_file, 'dsgvo/') || !Storage::disk('local')->exists($user->dsgvo_consent_file)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mimeType = Storage::disk('local')->mimeType($user->dsgvo_consent_file);
|
||||
|
||||
return response()->file(
|
||||
Storage::disk('local')->path($user->dsgvo_consent_file),
|
||||
['Content-Type' => $mimeType]
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
// Admin (ID 1) kann nicht selbst löschen
|
||||
if ($user->id === 1) {
|
||||
return back()->with('error', __('profile.cannot_delete_admin'));
|
||||
}
|
||||
|
||||
// Staff (Admin/Trainer) können sich nicht über die Profilseite löschen
|
||||
if ($user->isStaff()) {
|
||||
return back()->with('error', __('profile.cannot_delete_staff'));
|
||||
}
|
||||
|
||||
// Verwaiste Kinder ermitteln und deaktivieren
|
||||
$orphanedChildren = $user->getOrphanedChildren();
|
||||
$orphanedChildNames = [];
|
||||
|
||||
foreach ($orphanedChildren as $child) {
|
||||
$child->delete();
|
||||
$orphanedChildNames[] = $child->full_name;
|
||||
|
||||
ActivityLog::log(
|
||||
'child_auto_deactivated',
|
||||
__('admin.log_child_auto_deactivated', [
|
||||
'child' => $child->full_name,
|
||||
'parent' => $user->name,
|
||||
]),
|
||||
'Player',
|
||||
$child->id,
|
||||
['parent_user_id' => $user->id, 'reason' => 'sole_parent_self_deleted']
|
||||
);
|
||||
}
|
||||
|
||||
// Selbstlöschung loggen
|
||||
ActivityLog::logWithChanges(
|
||||
'account_self_deleted',
|
||||
__('admin.log_account_self_deleted', ['name' => $user->name]),
|
||||
'User',
|
||||
$user->id,
|
||||
[
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role->value,
|
||||
'orphaned_children' => $orphanedChildNames,
|
||||
],
|
||||
null
|
||||
);
|
||||
|
||||
// Logout + Session invalidieren
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// User soft-deleten
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('login')->with('success', __('profile.account_deleted'));
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/TimekeeperController.php
Executable file
55
app/Http/Controllers/TimekeeperController.php
Executable file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CateringStatus;
|
||||
use App\Enums\EventStatus;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventTimekeeper;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TimekeeperController extends Controller
|
||||
{
|
||||
public function update(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($event->status === EventStatus::Cancelled) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$event->type->hasTimekeepers()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
if ($event->status === EventStatus::Draft) {
|
||||
abort(403);
|
||||
}
|
||||
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'status' => 'required|in:yes,no,unknown',
|
||||
]);
|
||||
|
||||
$existing = EventTimekeeper::where('event_id', $event->id)->where('user_id', auth()->id())->first();
|
||||
$oldStatus = $existing?->status?->value ?? 'unknown';
|
||||
|
||||
$timekeeper = EventTimekeeper::where('event_id', $event->id)->where('user_id', auth()->id())->first();
|
||||
if (!$timekeeper) {
|
||||
$timekeeper = new EventTimekeeper(['event_id' => $event->id]);
|
||||
$timekeeper->user_id = auth()->id();
|
||||
}
|
||||
$timekeeper->status = CateringStatus::from($request->status);
|
||||
$timekeeper->save();
|
||||
|
||||
ActivityLog::logWithChanges('status_changed', __('admin.log_timekeeper_changed', ['event' => $event->title, 'status' => $request->status]), 'Event', $event->id, ['status' => $oldStatus, 'user_id' => auth()->id(), 'source' => 'timekeeper'], ['status' => $request->status, 'user_id' => auth()->id(), 'source' => 'timekeeper']);
|
||||
|
||||
return redirect(route('events.show', $event) . '#timekeeper');
|
||||
}
|
||||
}
|
||||
25
app/Http/Middleware/ActiveUserMiddleware.php
Executable file
25
app/Http/Middleware/ActiveUserMiddleware.php
Executable file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ActiveUserMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->user() && (!$request->user()->is_active || $request->user()->trashed())) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')
|
||||
->with('error', __('auth_ui.account_deactivated'));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/AdminMiddleware.php
Executable file
19
app/Http/Middleware/AdminMiddleware.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$request->user() || !$request->user()->canAccessAdminPanel()) {
|
||||
abort(403, 'Zugriff verweigert.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/AdminOnlyMiddleware.php
Normal file
19
app/Http/Middleware/AdminOnlyMiddleware.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminOnlyMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$request->user() || !$request->user()->isAdmin()) {
|
||||
abort(403, 'Zugriff verweigert.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
21
app/Http/Middleware/DsgvoConsentMiddleware.php
Normal file
21
app/Http/Middleware/DsgvoConsentMiddleware.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DsgvoConsentMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user && $user->isDsgvoRestricted()) {
|
||||
return back()->with('error', __('ui.dsgvo_restricted'));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
81
app/Http/Middleware/InstallerMiddleware.php
Normal file
81
app/Http/Middleware/InstallerMiddleware.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InstallerMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Auto-create .env from .env.example if missing (first-time deployment)
|
||||
$envPath = base_path('.env');
|
||||
if (! file_exists($envPath) && file_exists(base_path('.env.example'))) {
|
||||
copy(base_path('.env.example'), $envPath);
|
||||
}
|
||||
|
||||
$isInstalled = file_exists(storage_path('installed'));
|
||||
$isInstallerRoute = $request->is('install') || $request->is('install/*');
|
||||
|
||||
// Not installed + not on installer routes → redirect to installer
|
||||
if (! $isInstalled && ! $isInstallerRoute) {
|
||||
// Allow static assets and health check through
|
||||
if ($request->is('favicon.ico', 'images/*', 'up', 'manifest.json', 'sw.js', 'storage/*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect('/install');
|
||||
}
|
||||
|
||||
// Already installed + on installer routes → redirect to login
|
||||
if ($isInstalled && $isInstallerRoute) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
// Setup-Token-Schutz: Installer nur mit gültigem Token erreichbar
|
||||
if (! $isInstalled && $isInstallerRoute) {
|
||||
$tokenFile = storage_path('setup-token');
|
||||
|
||||
// Token-Datei beim ersten Zugriff generieren
|
||||
if (! file_exists($tokenFile)) {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
file_put_contents($tokenFile, $token);
|
||||
chmod($tokenFile, 0600);
|
||||
// Nur Token-Hash loggen (Klartext in Datei storage/setup-token)
|
||||
logger()->warning("Installer Setup-Token generiert (SHA256: " . hash('sha256', $token) . ")");
|
||||
logger()->warning("Token befindet sich in: storage/setup-token");
|
||||
}
|
||||
|
||||
$expectedToken = trim(file_get_contents($tokenFile));
|
||||
$providedToken = $request->query('token');
|
||||
// Session ist ggf. noch nicht gestartet (Middleware laeuft vor StartSession)
|
||||
$sessionTokenHash = $request->hasSession() ? $request->session()->get('setup_token_hash') : null;
|
||||
|
||||
if ($providedToken && hash_equals($expectedToken, $providedToken)) {
|
||||
// Token-Hash in Session speichern — kein Klartext in Session (V11)
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->put('setup_token_hash', hash('sha256', $expectedToken));
|
||||
}
|
||||
} elseif ($sessionTokenHash && hash_equals(hash('sha256', $expectedToken), $sessionTokenHash)) {
|
||||
// Gültiges Token via Session-Hash
|
||||
} elseif (! $request->is('install')) {
|
||||
// Nur die Startseite ohne Token erlauben (zeigt Token-Eingabe)
|
||||
abort(403, 'Ungültiges Setup-Token.');
|
||||
}
|
||||
}
|
||||
|
||||
// Force file-based session/cache during installation (DB may not exist yet).
|
||||
// Fixed cookie name prevents session loss when APP_NAME changes in .env mid-install.
|
||||
if (! $isInstalled && $isInstallerRoute) {
|
||||
config([
|
||||
'session.driver' => 'file',
|
||||
'cache.default' => 'file',
|
||||
'session.cookie' => 'handball_installer_session',
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
61
app/Http/Middleware/SecurityHeadersMiddleware.php
Executable file
61
app/Http/Middleware/SecurityHeadersMiddleware.php
Executable file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SecurityHeadersMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
// Server-Fingerprinting verhindern
|
||||
header_remove('X-Powered-By');
|
||||
$response->headers->remove('X-Powered-By');
|
||||
$response->headers->remove('Server');
|
||||
|
||||
// Content Security Policy — erlaubt CDN-Quellen für Tailwind, Alpine, Quill, Leaflet
|
||||
// 'unsafe-inline' benötigt von: Tailwind CDN (inline Styles), Alpine.js (Event-Handler)
|
||||
// 'unsafe-eval' benötigt von: Tailwind CDN (JIT nutzt new Function())
|
||||
// Entfernung nur möglich durch Wechsel auf self-hosted/kompilierte Assets
|
||||
$cspDirectives = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com https://cdn.quilljs.com",
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://unpkg.com https://cdn.quilljs.com",
|
||||
"img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com",
|
||||
"font-src 'self' https://cdn.jsdelivr.net https://cdn.quilljs.com",
|
||||
"connect-src 'self' https://photon.komoot.io",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
];
|
||||
|
||||
// upgrade-insecure-requests nur bei HTTPS — bricht sonst lokale HTTP-Entwicklung (Herd/artisan serve)
|
||||
if ($request->secure()) {
|
||||
$cspDirectives[] = "upgrade-insecure-requests";
|
||||
}
|
||||
|
||||
$csp = implode('; ', $cspDirectives);
|
||||
|
||||
$response->headers->set('Content-Security-Policy', $csp);
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self), payment=(), usb=(), bluetooth=(), autoplay=(), magnetometer=(), gyroscope=(), accelerometer=()');
|
||||
|
||||
// Cross-Origin Isolation Headers
|
||||
$response->headers->set('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
|
||||
|
||||
// HSTS — HTTPS fuer 1 Jahr erzwingen (nur bei HTTPS-Requests aktiv)
|
||||
if ($request->secure()) {
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
44
app/Http/Middleware/SetLocaleMiddleware.php
Executable file
44
app/Http/Middleware/SetLocaleMiddleware.php
Executable file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocaleMiddleware
|
||||
{
|
||||
private const SUPPORTED_LOCALES = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = $this->resolveLocale($request);
|
||||
|
||||
app()->setLocale($locale);
|
||||
Carbon::setLocale($locale);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function resolveLocale(Request $request): string
|
||||
{
|
||||
// 1. Eingeloggter User → DB-Präferenz
|
||||
if ($request->user() && in_array($request->user()->locale, self::SUPPORTED_LOCALES)) {
|
||||
return $request->user()->locale;
|
||||
}
|
||||
|
||||
// 2. Session (für Gastseiten)
|
||||
if (session()->has('locale') && in_array(session('locale'), self::SUPPORTED_LOCALES)) {
|
||||
return session('locale');
|
||||
}
|
||||
|
||||
// 3. Fallback
|
||||
return 'de';
|
||||
}
|
||||
|
||||
public static function supportedLocales(): array
|
||||
{
|
||||
return self::SUPPORTED_LOCALES;
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/StaffMiddleware.php
Normal file
19
app/Http/Middleware/StaffMiddleware.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StaffMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$request->user() || !$request->user()->isStaff()) {
|
||||
abort(403, 'Zugriff verweigert.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
69
app/Models/ActivityLog.php
Normal file
69
app/Models/ActivityLog.php
Normal 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
46
app/Models/Comment.php
Executable 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
291
app/Models/Event.php
Executable 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
35
app/Models/EventCatering.php
Executable 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
56
app/Models/EventParticipant.php
Executable 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
26
app/Models/EventTimekeeper.php
Executable 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
34
app/Models/Faq.php
Executable 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
115
app/Models/File.php
Normal 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',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Models/FileCategory.php
Normal file
49
app/Models/FileCategory.php
Normal 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
52
app/Models/Invitation.php
Executable 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
10
app/Models/Location.php
Executable 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
77
app/Models/Player.php
Executable 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
47
app/Models/Setting.php
Executable 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
67
app/Models/Team.php
Executable 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
202
app/Models/User.php
Executable 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);
|
||||
}
|
||||
}
|
||||
51
app/Notifications/ResetPasswordNotification.php
Normal file
51
app/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ResetPasswordNotification extends ResetPassword
|
||||
{
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$url = $this->resetUrl($notifiable);
|
||||
$appName = config('app.name');
|
||||
$locale = $notifiable->locale ?? app()->getLocale();
|
||||
|
||||
// Versuche den vom Admin angepassten E-Mail-Text zu laden
|
||||
$customBody = Setting::get('password_reset_email_' . $locale)
|
||||
?: Setting::get('password_reset_email_de');
|
||||
|
||||
// Bereinige HTML-Tags für die E-Mail (einfacher Text aus Rich-Text)
|
||||
if ($customBody && strip_tags($customBody) !== '') {
|
||||
$plainBody = strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>'], "\n", $customBody));
|
||||
$plainBody = str_replace(
|
||||
['{name}', '{app_name}', '{link}'],
|
||||
[$notifiable->name, $appName, $url],
|
||||
$plainBody
|
||||
);
|
||||
|
||||
$lines = array_filter(array_map('trim', explode("\n", $plainBody)));
|
||||
|
||||
$mail = (new MailMessage)
|
||||
->subject(__('passwords.reset_subject', ['app' => $appName], $locale));
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$mail->line($line);
|
||||
}
|
||||
|
||||
return $mail->action(__('auth_ui.reset_password_button', [], $locale), $url);
|
||||
}
|
||||
|
||||
// Fallback: Standard-Laravel-Template
|
||||
return (new MailMessage)
|
||||
->subject(__('passwords.reset_subject', ['app' => $appName], $locale))
|
||||
->greeting(__('passwords.reset_greeting', ['name' => $notifiable->name], $locale))
|
||||
->line(__('passwords.reset_line1', [], $locale))
|
||||
->action(__('auth_ui.reset_password_button', [], $locale), $url)
|
||||
->line(__('passwords.reset_line2', ['count' => config('auth.passwords.users.expire')], $locale))
|
||||
->line(__('passwords.reset_line3', [], $locale));
|
||||
}
|
||||
}
|
||||
19
app/Policies/CateringPolicy.php
Executable file
19
app/Policies/CateringPolicy.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EventCatering;
|
||||
use App\Models\User;
|
||||
|
||||
class CateringPolicy
|
||||
{
|
||||
public function update(User $user, EventCatering $catering): bool
|
||||
{
|
||||
return $user->id === $catering->user_id || $user->isAdmin();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
19
app/Policies/CommentPolicy.php
Executable file
19
app/Policies/CommentPolicy.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\User;
|
||||
|
||||
class CommentPolicy
|
||||
{
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(User $user, Comment $comment): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
}
|
||||
39
app/Policies/EventPolicy.php
Executable file
39
app/Policies/EventPolicy.php
Executable file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
|
||||
class EventPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Event $event): bool
|
||||
{
|
||||
if ($event->status === EventStatus::Draft) {
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function update(User $user, Event $event): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function delete(User $user, Event $event): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
}
|
||||
22
app/Policies/ParticipantPolicy.php
Executable file
22
app/Policies/ParticipantPolicy.php
Executable file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ParticipantPolicy
|
||||
{
|
||||
public function update(User $user, EventParticipant $participant): bool
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DB::table('parent_player')
|
||||
->where('parent_id', $user->id)
|
||||
->where('player_id', $participant->player_id)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
19
app/Policies/TimekeeperPolicy.php
Normal file
19
app/Policies/TimekeeperPolicy.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EventTimekeeper;
|
||||
use App\Models\User;
|
||||
|
||||
class TimekeeperPolicy
|
||||
{
|
||||
public function update(User $user, EventTimekeeper $timekeeper): bool
|
||||
{
|
||||
return $user->id === $timekeeper->user_id || $user->isAdmin();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
71
app/Providers/AppServiceProvider.php
Executable file
71
app/Providers/AppServiceProvider.php
Executable file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\GeocodingService;
|
||||
use App\Services\HtmlSanitizerService;
|
||||
use App\Services\InvitationService;
|
||||
use App\Services\SupportApiService;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(HtmlSanitizerService::class);
|
||||
$this->app->singleton(GeocodingService::class);
|
||||
$this->app->singleton(InvitationService::class);
|
||||
$this->app->singleton(SupportApiService::class);
|
||||
|
||||
// During installation: ensure .env exists, APP_KEY is set,
|
||||
// and session/cache use file driver (database may not exist yet).
|
||||
// This must happen in register() — before middleware and any cache access.
|
||||
if (! file_exists(storage_path('installed'))) {
|
||||
$envPath = base_path('.env');
|
||||
|
||||
if (! file_exists($envPath) && file_exists(base_path('.env.example'))) {
|
||||
copy(base_path('.env.example'), $envPath);
|
||||
}
|
||||
|
||||
if (file_exists($envPath) && empty(config('app.key'))) {
|
||||
$key = 'base64:'.base64_encode(random_bytes(32));
|
||||
$envContent = file_get_contents($envPath);
|
||||
$envContent = preg_replace('/^APP_KEY=.*$/m', 'APP_KEY='.$key, $envContent);
|
||||
file_put_contents($envPath, $envContent);
|
||||
config(['app.key' => $key]);
|
||||
}
|
||||
|
||||
// Session und Cache auf Datei-basiert umschalten.
|
||||
// Die .env hat SESSION_DRIVER=database und CACHE_STORE=database,
|
||||
// aber vor der Installation existiert die Datenbank noch nicht.
|
||||
config([
|
||||
'session.driver' => 'file',
|
||||
'cache.default' => 'file',
|
||||
'session.cookie' => 'handball_installer_session',
|
||||
'session.encrypt' => false, // Verschluesselung braucht stabilen Key; bei Erstinstall unnoetig
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('registration', function (Request $request) {
|
||||
return Limit::perHour(5)->by($request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('user-actions', function (Request $request) {
|
||||
return Limit::perMinute(30)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('geocoding', function (Request $request) {
|
||||
return Limit::perMinute(30)->by($request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
83
app/Services/GeocodingService.php
Executable file
83
app/Services/GeocodingService.php
Executable file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class GeocodingService
|
||||
{
|
||||
/** Erlaubte Geocoding-Hosts (SSRF-Schutz) */
|
||||
private const ALLOWED_HOSTS = [
|
||||
'nominatim.openstreetmap.org',
|
||||
'photon.komoot.io',
|
||||
];
|
||||
|
||||
public function search(string $query): array
|
||||
{
|
||||
$baseUrl = config('nominatim.base_url');
|
||||
|
||||
// SSRF-Schutz: Nur erlaubte Hosts und HTTPS
|
||||
$parsedHost = parse_url($baseUrl, PHP_URL_HOST);
|
||||
if (!$parsedHost || !in_array($parsedHost, self::ALLOWED_HOSTS)) {
|
||||
return [];
|
||||
}
|
||||
if (parse_url($baseUrl, PHP_URL_SCHEME) !== 'https') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cacheKey = 'geocode:' . hash('sha256', mb_strtolower(trim($query)));
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => config('nominatim.user_agent'),
|
||||
])->timeout(5)->get($baseUrl . '/search', [
|
||||
'q' => $query,
|
||||
'format' => 'json',
|
||||
'addressdetails' => 1,
|
||||
'namedetails' => 1,
|
||||
'limit' => 5,
|
||||
'countrycodes' => 'de,at,ch',
|
||||
'accept-language' => 'de',
|
||||
]);
|
||||
|
||||
// Fehlerhafte Responses nicht cachen (V20)
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = collect($response->json())->map(function ($item) {
|
||||
$addr = $item['address'] ?? [];
|
||||
|
||||
// Structured address from components
|
||||
$street = trim(($addr['road'] ?? '') . ' ' . ($addr['house_number'] ?? ''));
|
||||
$postcode = $addr['postcode'] ?? '';
|
||||
$city = $addr['city'] ?? $addr['town'] ?? $addr['village'] ?? $addr['municipality'] ?? '';
|
||||
$name = $item['namedetails']['name'] ?? '';
|
||||
|
||||
// Build formatted address line
|
||||
$parts = array_filter([$street, implode(' ', array_filter([$postcode, $city]))]);
|
||||
$formatted = implode(', ', $parts);
|
||||
|
||||
return [
|
||||
'display_name' => $item['display_name'],
|
||||
'formatted_address' => $formatted ?: $item['display_name'],
|
||||
'name' => $name,
|
||||
'street' => $street,
|
||||
'postcode' => $postcode,
|
||||
'city' => $city,
|
||||
'lat' => $item['lat'],
|
||||
'lon' => $item['lon'],
|
||||
'type' => $item['type'] ?? '',
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
Cache::put($cacheKey, $results, 86400);
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
30
app/Services/HtmlSanitizerService.php
Executable file
30
app/Services/HtmlSanitizerService.php
Executable file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use HTMLPurifier;
|
||||
use HTMLPurifier_Config;
|
||||
|
||||
class HtmlSanitizerService
|
||||
{
|
||||
private HTMLPurifier $purifier;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = HTMLPurifier_Config::createDefault();
|
||||
$config->set('HTML.Allowed', 'p,br,strong,b,em,i,u,ul,ol,li,a[href|target],h2[id],h3[id],h4[id],blockquote,span[style]');
|
||||
$config->set('CSS.AllowedProperties', 'color,background-color');
|
||||
$config->set('HTML.TargetBlank', true);
|
||||
$config->set('AutoFormat.RemoveEmpty', true);
|
||||
// DOM-Clobbering-Schutz: IDs in User-Content prefixen (V18)
|
||||
$config->set('Attr.IDPrefix', 'uc-');
|
||||
$config->set('Cache.SerializerPath', storage_path('app/purifier'));
|
||||
|
||||
$this->purifier = new HTMLPurifier($config);
|
||||
}
|
||||
|
||||
public function sanitize(string $dirtyHtml): string
|
||||
{
|
||||
return $this->purifier->purify($dirtyHtml);
|
||||
}
|
||||
}
|
||||
62
app/Services/InvitationService.php
Executable file
62
app/Services/InvitationService.php
Executable file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Invitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class InvitationService
|
||||
{
|
||||
public function createInvitation(array $data, User $admin): Invitation
|
||||
{
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
|
||||
$invitation = new Invitation([
|
||||
'email' => $data['email'] ?? null,
|
||||
'expires_at' => now()->addDays((int) ($data['expires_in_days'] ?? 7)),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Token gehasht speichern — Klartext nur in der URL (V05)
|
||||
$invitation->token = hash('sha256', $rawToken);
|
||||
$invitation->created_by = $admin->id;
|
||||
$invitation->save();
|
||||
|
||||
if (!empty($data['player_ids'])) {
|
||||
$invitation->players()->attach($data['player_ids']);
|
||||
}
|
||||
|
||||
// raw_token für die URL-Generierung bereitstellen (nicht persistiert)
|
||||
$invitation->raw_token = $rawToken;
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
public function redeemInvitation(Invitation $invitation, array $userData): User
|
||||
{
|
||||
return DB::transaction(function () use ($invitation, $userData) {
|
||||
$user = User::create([
|
||||
'name' => $userData['name'],
|
||||
'email' => $userData['email'],
|
||||
'password' => $userData['password'],
|
||||
]);
|
||||
$user->is_active = true;
|
||||
$user->role = UserRole::User;
|
||||
$user->save();
|
||||
|
||||
// Eltern-Kind-Zuordnungen aus der Einladung übernehmen
|
||||
foreach ($invitation->players as $player) {
|
||||
$user->children()->attach($player->id, [
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$invitation->accepted_at = now();
|
||||
$invitation->save();
|
||||
|
||||
return $user;
|
||||
});
|
||||
}
|
||||
}
|
||||
259
app/Services/SupportApiService.php
Normal file
259
app/Services/SupportApiService.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SupportApiService
|
||||
{
|
||||
private ?array $installedData = null;
|
||||
|
||||
// ─── Registration ────────────────────────────────────
|
||||
|
||||
public function register(array $data): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient()
|
||||
->post('/register', $data);
|
||||
|
||||
if ($response->successful()) {
|
||||
$result = $response->json();
|
||||
$this->saveCredentials(
|
||||
$result['installation_id'] ?? '',
|
||||
$result['api_token'] ?? ''
|
||||
);
|
||||
return $result;
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Support API registration failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function isRegistered(): bool
|
||||
{
|
||||
$data = $this->readInstalled();
|
||||
return !empty($data['installation_id']) && !empty($data['api_token']);
|
||||
}
|
||||
|
||||
// ─── License ─────────────────────────────────────────
|
||||
|
||||
public function validateLicense(string $key): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->authenticatedClient()
|
||||
->post('/license/validate', ['license_key' => $key]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$result = $response->json();
|
||||
Cache::put('support.license_valid', $result['valid'] ?? false, 86400);
|
||||
return $result;
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('License validation failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Updates ─────────────────────────────────────────
|
||||
|
||||
public function checkForUpdate(bool $force = false): ?array
|
||||
{
|
||||
$cacheKey = 'support.update_check';
|
||||
|
||||
if (!$force && Cache::has($cacheKey)) {
|
||||
return Cache::get($cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
$params = [
|
||||
'current_version' => config('app.version'),
|
||||
'app_name' => \App\Models\Setting::get('app_name', config('app.name')),
|
||||
];
|
||||
$logoUrl = $this->getLogoUrl();
|
||||
if ($logoUrl) {
|
||||
$params['logo_url'] = $logoUrl;
|
||||
}
|
||||
|
||||
$response = $this->authenticatedClient()
|
||||
->get('/version/check', $params);
|
||||
|
||||
if ($response->successful()) {
|
||||
$result = $response->json();
|
||||
Cache::put($cacheKey, $result, 86400);
|
||||
return $result;
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Update check failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasUpdate(): bool
|
||||
{
|
||||
$cached = Cache::get('support.update_check');
|
||||
if (!$cached) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return version_compare($cached['latest_version'] ?? '0.0.0', config('app.version'), '>');
|
||||
}
|
||||
|
||||
// ─── Tickets ─────────────────────────────────────────
|
||||
|
||||
public function getTickets(): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->authenticatedClient()->get('/tickets');
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Ticket list fetch failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getTicket(int $id): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->authenticatedClient()->get("/tickets/{$id}");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Ticket #{$id} fetch failed: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function createTicket(array $data): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->authenticatedClient()->post('/tickets', $data);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Ticket creation failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function replyToTicket(int $id, array $data): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->authenticatedClient()
|
||||
->post("/tickets/{$id}/messages", $data);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Ticket #{$id} reply failed: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── System Info ─────────────────────────────────────
|
||||
|
||||
public function getSystemInfo(): array
|
||||
{
|
||||
return [
|
||||
'app_version' => config('app.version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'laravel_version' => app()->version(),
|
||||
'db_driver' => config('database.default'),
|
||||
'locale' => app()->getLocale(),
|
||||
'os' => PHP_OS,
|
||||
];
|
||||
}
|
||||
|
||||
public function getLogoUrl(): ?string
|
||||
{
|
||||
$favicon = \App\Models\Setting::get('app_favicon');
|
||||
if ($favicon) {
|
||||
return rtrim(config('app.url'), '/') . '/storage/' . $favicon;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Storage/Installed Access ────────────────────────
|
||||
|
||||
public function readInstalled(): array
|
||||
{
|
||||
if ($this->installedData !== null) {
|
||||
return $this->installedData;
|
||||
}
|
||||
|
||||
$path = storage_path('installed');
|
||||
if (!file_exists($path)) {
|
||||
return $this->installedData = [];
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
return $this->installedData = is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────
|
||||
|
||||
private function httpClient(): \Illuminate\Http\Client\PendingRequest
|
||||
{
|
||||
$apiUrl = config('support.api_url');
|
||||
|
||||
// SSRF-Schutz: Nur HTTPS und keine privaten IPs (T06)
|
||||
$parsed = parse_url($apiUrl);
|
||||
$scheme = $parsed['scheme'] ?? '';
|
||||
$host = $parsed['host'] ?? '';
|
||||
|
||||
if ($scheme !== 'https') {
|
||||
throw new \RuntimeException('Support API URL must use HTTPS.');
|
||||
}
|
||||
|
||||
$ip = gethostbyname($host);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
||||
throw new \RuntimeException('Support API URL must not resolve to a private/reserved IP.');
|
||||
}
|
||||
|
||||
// DNS-Rebinding verhindern: aufgelöste IP direkt verwenden (V07)
|
||||
$resolvedUrl = str_replace($host, $ip, $apiUrl);
|
||||
|
||||
return Http::baseUrl($resolvedUrl)
|
||||
->timeout(config('support.timeout', 10))
|
||||
->connectTimeout(config('support.connect_timeout', 5))
|
||||
->withHeaders(['Accept' => 'application/json', 'Host' => $host]);
|
||||
}
|
||||
|
||||
private function authenticatedClient(): \Illuminate\Http\Client\PendingRequest
|
||||
{
|
||||
$token = $this->readInstalled()['api_token'] ?? '';
|
||||
return $this->httpClient()->withToken($token);
|
||||
}
|
||||
|
||||
private function saveCredentials(string $installationId, string $apiToken): void
|
||||
{
|
||||
$path = storage_path('installed');
|
||||
$data = $this->readInstalled();
|
||||
$data['installation_id'] = $installationId;
|
||||
$data['api_token'] = $apiToken;
|
||||
$data['registered_at'] = now()->toIso8601String();
|
||||
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));
|
||||
chmod($path, 0600);
|
||||
|
||||
// Reset memoized data
|
||||
$this->installedData = $data;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user