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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user