Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill

- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er)
- Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link
- Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest
- Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen)
- Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

86
.env.example Executable file
View File

@@ -0,0 +1,86 @@
APP_NAME="Handball App"
APP_ENV=local
APP_KEY=
# ACHTUNG: In Produktion IMMER auf false setzen!
APP_DEBUG=false
APP_TIMEZONE="Europe/Berlin"
APP_URL=http://localhost:8000
APP_LOCALE=de
APP_FALLBACK_LOCALE=de
APP_FAKER_LOCALE=de_DE
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=handball_app
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=true
SESSION_PATH=/
SESSION_DOMAIN=null
# Sicherheits-Defaults: In Produktion (HTTPS) muessen diese aktiv sein!
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="noreply@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# Nominatim Geocoding (OpenStreetMap)
NOMINATIM_USER_AGENT="HandballApp/1.0 kontakt@example.de"
NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
# Admin-Seeder (PFLICHT — keine Fallback-Werte mehr!)
ADMIN_EMAIL=admin@handball.local
ADMIN_PASSWORD=
# Support-API (optional)
# SUPPORT_API_URL=https://support.rhino.nrw/api/v1
# -------------------------------------------------------
# PRODUKTION — folgende Werte vor dem Deployment setzen!
# -------------------------------------------------------
# APP_ENV=production
# APP_DEBUG=false
# APP_URL=https://handball.example.com
# LOG_CHANNEL=daily
# LOG_DAILY_DAYS=30
# LOG_LEVEL=warning
#
# SESSION_ENCRYPT=true
# SESSION_SECURE_COOKIE=true
# SESSION_SAME_SITE=strict
#
# DB_CONNECTION=mysql
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=handball_app
# DB_USERNAME=handball_user
# DB_PASSWORD=
#
# SQLite (nur lokal): Dateiberechtigungen auf 640 setzen!
# chmod 640 database/database.sqlite

BIN
Archiv.zip Normal file

Binary file not shown.

121
CLAUDE.md Normal file
View File

@@ -0,0 +1,121 @@
# WebApp_Install — Handball Team Manager
## Projekt-Typ
Laravel 12 WebApp zur Verwaltung von Handball-Teams, Spielern, Eltern, Terminen und Dateien.
PHP 8.2+, SQLite/MySQL, Blade + Alpine.js + Tailwind CSS, Quill.js WYSIWYG-Editor.
## Architektur
### Verzeichnisstruktur
- `app/Models/` — Eloquent Models (User, Player, Team, Event, Setting, etc.)
- `app/Http/Controllers/Admin/` — Admin-Bereich (UserController, SettingsController, etc.)
- `app/Http/Controllers/Auth/` — Login, Register, ForgotPassword, ResetPassword
- `app/Http/Middleware/` — InstallerMiddleware (Setup-Token), SetLocaleMiddleware, SecurityHeadersMiddleware, ActiveUserMiddleware, DsgvoConsentMiddleware
- `app/Services/` — HtmlSanitizerService (HTMLPurifier), GeocodingService, SupportApiService
- `app/Enums/` — UserRole, EventType, EventStatus, ParticipantStatus, CateringStatus
- `app/Notifications/` — ResetPasswordNotification (Custom, mit Setting-Template)
- `resources/views/` — Blade-Templates (layouts: admin, guest, app)
- `lang/{de,en,pl,ru,ar,tr}/` — 6 Sprachen: Deutsch, Englisch, Polnisch, Russisch, Arabisch, Turkisch
- `database/migrations/` — Nummeriert mit Prefix 0001-0035
- `database/seeders/` — AdminSeeder (benotigt ADMIN_EMAIL + ADMIN_PASSWORD in .env)
### Wichtige Patterns
- **Settings**: Key-Value Store in `settings` Tabelle. `Setting::get($key)` mit 1-Stunden-Cache, `Setting::set($key, $value)` mit Cache-Invalidierung
- **Locale-Settings**: Rechtliche Texte und E-Mail-Templates sind locale-suffixed: `impressum_html_de`, `datenschutz_html_en`, `password_reset_email_pl` etc.
- **Passwort-Hashing**: User-Model nutzt Laravel Cast `'password' => 'hashed'` — KEIN manuelles Hash::make beim Setzen via `$user->password = $pw` noetig
- **HTML-Output**: Alle `{!! !!}`-Ausgaben von User-Content muessen durch `HtmlSanitizerService::sanitize()` gehen
- **Rollen**: Admin, Coach, ParentRep, User (Enum `UserRole`)
- **Soft-Deletes**: User und Player (7 Tage Wiederherstellung)
- **Activity-Log**: `ActivityLog::log()` / `ActivityLog::logWithChanges()` fuer Audit-Trail
### Multi-Language
- 6 Sprachen: de, en, pl, ru, ar, tr
- Translation-Dateien: `lang/{locale}/admin.php`, `auth_ui.php`, `passwords.php`, `ui.php`, `events.php`, `profile.php`, `validation.php`
- Locale wird per SetLocaleMiddleware aus `$user->locale` oder Session gesetzt
- RTL-Support fuer Arabisch (ar)
### Password Reset Flow
1. User klickt "Passwort vergessen?" auf Login-Seite
2. ForgotPasswordController sendet Reset-Link via Laravel Password Broker
3. ResetPasswordNotification laedt optionalen Custom-Template-Text aus `Setting::get('password_reset_email_{locale}')`
4. Platzhalter: `{name}`, `{link}`, `{app_name}`
5. Fallback auf Standard-Laravel-Template mit Translation-Keys (`passwords.reset_*`)
### Installer
- InstallerMiddleware prueft `storage/installed` Datei
- Setup-Token-Schutz: Token in `storage/setup-token`, wird beim ersten Zugriff generiert und in Laravel-Log geschrieben
- Nach Installation wird `storage/installed` erstellt
### Security
- CSP und Permissions-Policy via SecurityHeadersMiddleware (inkl. COOP-Header)
- Honeypot-Feld auf Login/Register-Formularen
- Rate-Limiting auf Auth-Routes (`throttle:login`)
- DSGVO-Consent-System mit Datei-Upload und Admin-Bestaetigung
- Factory Reset benoetigt Passwort + Bestaetigung "RESET-BESTAETIGT"
- **File-Autorisierung**: `authorizeFileAccess()` in FileController prueft Team-Zugehoerigkeit und aktive Kategorien
- **Settings-Whitelist**: SettingsController akzeptiert nur bekannte Keys + validierte Locale-Suffixe
- **SSRF-Schutz**: GeocodingService mit Host-Whitelist (nominatim.openstreetmap.org, photon.komoot.io), HTTPS-only, Timeout 5s
- **Installer-Sicherheit**: Admin-Passwort wird sofort gehasht (nicht Klartext in Session), Setup-Token nur als SHA256 geloggt
- **Path-Traversal-Schutz**: DSGVO-Datei-Downloads pruefen `str_starts_with('dsgvo/')`
- **HTML-Sanitisierung**: Alle `{!! !!}`-Ausgaben (Slogan, Settings-Editor, PWA-Banner) durch `HtmlSanitizerService::sanitize()` geschuetzt
## Conventions
- Controller-Methoden: resourceful (index, create, store, show, edit, update, destroy)
- Blade-Components: `<x-layouts.admin>`, `<x-layouts.guest>`, `<x-layouts.app>`
- Alpine.js fuer interaktive UI-Elemente
- Quill.js v1.3.7 fuer WYSIWYG-Editoren (via CDN)
- Keine Tests vorhanden — bei Aenderungen manuell testen
- Commit-Sprache: Deutsch oder Englisch
- Alle Uebersetzungsschluessel muessen in ALLEN 6 Sprachen hinzugefuegt werden
## Haeufige Aufgaben
### Neuen Translation-Key hinzufuegen
Immer in allen 6 Dateien: `lang/de/`, `lang/en/`, `lang/pl/`, `lang/ru/`, `lang/ar/`, `lang/tr/`
### Neues Setting hinzufuegen
1. Migration erstellen: `Setting::firstOrCreate(['key' => '...'], [...])`
2. In SettingsController `edit()` laden
3. In View rendern
4. In `update()` speichern (mit Sanitization fuer HTML)
### Neues locale-spezifisches Setting
1. Keys: `{setting_name}_{locale}` (z.B. `impressum_html_de`)
2. Migration fuer alle 6 Locales
3. In SettingsController `$localeSettings` Array aufnehmen
4. In View mit Sprach-Flaggen-Selector anzeigen
5. In `update()` mit `str_starts_with()` und `updateOrCreate()` behandeln
6. **Wichtig**: Neuen Key zur Whitelist in `SettingsController::update()` hinzufuegen ($allowedLocaleKeys)
## Security Audit & Haertung (Maerz 2026)
### Audit-Report
- **Datei**: `security-audit-2026-03.html` — Vollstaendiger HTML-Report mit allen Findings und Fix-Dokumentation
- **Score**: 6.5 → 8.5 / 10 nach Haertung
- **Ergebnis**: 20 Findings identifiziert, 19 behoben, 1 als akzeptiertes Risiko
### Behobene Schwachstellen (nach Datei)
| Datei | Findings | Aenderungen |
|-------|----------|-------------|
| `FileController.php` | F01 (Kritisch) | `authorizeFileAccess()` — IDOR-Schutz fuer Downloads/Previews |
| `admin/settings/edit.blade.php` | F02 (Kritisch) | Alle `{!! !!}` durch `HtmlSanitizerService::sanitize()` |
| `layouts/app.blade.php`, `guest.blade.php` | F03 (Kritisch) | Slogan-Ausgabe sanitisiert |
| `Admin/SettingsController.php` | F04 (Hoch) | Settings-Key-Whitelist in `update()` |
| `Services/GeocodingService.php` | F06 (Hoch) | ALLOWED_HOSTS, HTTPS-only, SHA256-Cache, Timeout |
| `EventController.php` | F07 (Hoch) | `accessibleTeamIds()`, EventType-Validierung, int-Cast |
| `InstallerController.php` | F08 (Mittel) | Passwort sofort gehasht (`installer.admin_password_hash`) |
| `InstallerMiddleware.php` | F09 (Mittel) | Token als SHA256 geloggt, chmod 0600 |
| `SecurityHeadersMiddleware.php` | F10, F19 | COOP-Header hinzugefuegt |
| `.env.example` | F11 (Mittel) | SESSION_SECURE_COOKIE + SESSION_SAME_SITE Defaults |
| `Admin/TeamController.php` | F12 (Mittel) | Ziel-Team aktiv-Pruefung in `updatePlayerTeam()` |
| `Admin/ActivityLogController.php` | F13, F16 | whereRaw→Eloquent, `canViewActivityLog()` statt `id !== 1` |
| `CommentController.php` | F14 (Mittel) | `e()` entfernt (Doppel-Escaping behoben) |
| `pwa-install-banner.blade.php` | F15 (Mittel) | Translation-Ausgabe sanitisiert |
| `ProfileController.php` | F17 (Niedrig) | DSGVO Path-Prefix-Check |
| `Auth/ResetPasswordController.php` | F18 (Niedrig) | `Password::min(8)->letters()->numbers()` |
### Akzeptierte Restrisiken
- **F05** (MIME-Spoofing): UUID-Dateinamen + privater Storage + Autorisierung mitigieren das Risiko
- **F20** (EventTimekeeper Enum): CateringStatus und TimekeeperStatus haben identische Werte
- **CSP unsafe-inline/eval**: CDN-Abhaengigkeit (Tailwind/Alpine.js/Quill.js) erfordert dies — langfristig lokale Assets empfohlen

59
README.md Executable file
View File

@@ -0,0 +1,59 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

15
app/Enums/CateringStatus.php Executable file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum CateringStatus: string
{
case Yes = 'yes';
case No = 'no';
case Unknown = 'unknown';
public function label(): string
{
return __("ui.enums.catering_status.{$this->value}");
}
}

15
app/Enums/EventStatus.php Executable file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum EventStatus: string
{
case Published = 'published';
case Cancelled = 'cancelled';
case Draft = 'draft';
public function label(): string
{
return __("ui.enums.event_status.{$this->value}");
}
}

38
app/Enums/EventType.php Executable file
View File

@@ -0,0 +1,38 @@
<?php
namespace App\Enums;
enum EventType: string
{
case HomeGame = 'home_game';
case AwayGame = 'away_game';
case Training = 'training';
case Tournament = 'tournament';
case Meeting = 'meeting';
case Other = 'other';
public function label(): string
{
return __("ui.enums.event_type.{$this->value}");
}
public function isGameType(): bool
{
return in_array($this, [self::HomeGame, self::AwayGame]);
}
public function hasCatering(): bool
{
return !in_array($this, [self::AwayGame, self::Meeting]);
}
public function hasTimekeepers(): bool
{
return !in_array($this, [self::AwayGame, self::Meeting]);
}
public function hasPlayerParticipants(): bool
{
return $this !== self::Meeting;
}
}

15
app/Enums/ParticipantStatus.php Executable file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum ParticipantStatus: string
{
case Yes = 'yes';
case No = 'no';
case Unknown = 'unknown';
public function label(): string
{
return __("ui.enums.participant_status.{$this->value}");
}
}

11
app/Enums/UserRole.php Executable file
View File

@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum UserRole: string
{
case Admin = 'admin';
case Coach = 'coach';
case ParentRep = 'parent_rep';
case User = 'user';
}

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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];
}
}

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

View 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')]);
}
}

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

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

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ActivityLog extends Model
{
public $timestamps = false;
protected $fillable = [
'user_id',
'action',
'model_type',
'model_id',
'description',
'properties',
'created_at',
];
protected function casts(): array
{
return [
'properties' => 'array',
'created_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
public static function log(
string $action,
string $description,
?string $modelType = null,
?int $modelId = null,
?array $properties = null,
): self {
$log = new static();
$log->user_id = auth()->id();
$log->action = $action;
$log->model_type = $modelType;
$log->model_id = $modelId;
$log->description = $description;
$log->properties = $properties;
$log->ip_address = request()->ip();
$log->created_at = now();
$log->save();
return $log;
}
public static function logWithChanges(
string $action,
string $description,
?string $modelType = null,
?int $modelId = null,
?array $old = null,
?array $new = null,
): self {
$properties = null;
if ($old !== null || $new !== null) {
$properties = array_filter(['old' => $old, 'new' => $new], fn ($v) => $v !== null);
}
return static::log($action, $description, $modelType, $modelId, $properties ?: null);
}
}

46
app/Models/Comment.php Executable file
View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
protected $fillable = [
'event_id',
'body',
];
protected function casts(): array
{
return [
'deleted_at' => 'datetime',
];
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
public function deletedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by')->withTrashed();
}
public function isDeleted(): bool
{
return $this->deleted_at !== null;
}
public function scopeVisible($query)
{
return $query->whereNull('deleted_at');
}
}

291
app/Models/Event.php Executable file
View File

@@ -0,0 +1,291 @@
<?php
namespace App\Models;
use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
class Event extends Model
{
use SoftDeletes;
protected $fillable = [
'team_id',
'type',
'title',
'start_at',
'end_at',
'status',
'location_name',
'address_text',
'location_lat',
'location_lng',
'description_html',
'min_players',
'min_catering',
'min_timekeepers',
'opponent',
'score_home',
'score_away',
];
protected function casts(): array
{
return [
'type' => EventType::class,
'status' => EventStatus::class,
'start_at' => 'datetime',
'end_at' => 'datetime',
'location_lat' => 'float',
'location_lng' => 'float',
'min_players' => 'integer',
'min_catering' => 'integer',
'min_timekeepers' => 'integer',
'score_home' => 'integer',
'score_away' => 'integer',
];
}
public function hasCoordinates(): bool
{
return $this->location_lat !== null && $this->location_lng !== null;
}
public function hasScore(): bool
{
return $this->type->isGameType() && ($this->score_home !== null || $this->score_away !== null);
}
public function scoreDisplay(): ?string
{
if (!$this->hasScore()) {
return null;
}
return ($this->score_home ?? '?') . ':' . ($this->score_away ?? '?');
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by')->withTrashed();
}
public function deletedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by')->withTrashed();
}
public function isRestorable(): bool
{
return $this->trashed() && $this->deleted_at->diffInDays(now()) <= 30;
}
public function participants(): HasMany
{
return $this->hasMany(EventParticipant::class);
}
public function caterings(): HasMany
{
return $this->hasMany(EventCatering::class);
}
public function timekeepers(): HasMany
{
return $this->hasMany(EventTimekeeper::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function faqs(): BelongsToMany
{
return $this->belongsToMany(Faq::class, 'event_faq');
}
public function files(): BelongsToMany
{
return $this->belongsToMany(File::class, 'event_file')->withPivot('created_at');
}
/**
* Check if all set minimums are met.
* Returns: true = all met, false = at least one not met, null = no minimums set.
*/
public function minimumsStatus(): ?bool
{
$hasAny = false;
$allMet = true;
if ($this->min_players !== null) {
$hasAny = true;
if ($this->type === EventType::Meeting) {
// Für Besprechungen: User mit Zusage zählen (user_id-basiert)
$count = $this->participants
->where('status', ParticipantStatus::Yes)
->whereNotNull('user_id')
->count();
} else {
$count = $this->participants->where('status', ParticipantStatus::Yes)->count();
}
if ($count < $this->min_players) {
$allMet = false;
}
}
// Catering/Zeitnehmer nur für Typen die es unterstützen
if ($this->type->hasCatering() && $this->min_catering !== null) {
$hasAny = true;
$cateringYes = $this->caterings_yes_count
?? $this->caterings->where('status', CateringStatus::Yes)->count();
if ($cateringYes < $this->min_catering) {
$allMet = false;
}
}
if ($this->type->hasTimekeepers() && $this->min_timekeepers !== null) {
$hasAny = true;
$timekeeperYes = $this->timekeepers_yes_count
?? $this->timekeepers->where('status', CateringStatus::Yes)->count();
if ($timekeeperYes < $this->min_timekeepers) {
$allMet = false;
}
}
return $hasAny ? $allMet : null;
}
/**
* Add missing active team players as participants (idempotent).
* For meetings, delegates to syncMeetingParticipants().
*/
public function syncParticipants(int $userId): void
{
if ($this->type === EventType::Meeting) {
$this->syncMeetingParticipants($userId);
return;
}
$activePlayerIds = $this->team->activePlayers()->pluck('id');
$existingPlayerIds = $this->participants()->pluck('player_id');
$missingPlayerIds = $activePlayerIds->diff($existingPlayerIds);
if ($missingPlayerIds->isEmpty()) {
return;
}
$now = now();
$records = $missingPlayerIds->map(fn ($playerId) => [
'event_id' => $this->id,
'player_id' => $playerId,
'status' => ParticipantStatus::Unknown->value,
'set_by_user_id' => $userId,
'created_at' => $now,
'updated_at' => $now,
])->toArray();
$this->participants()->insert($records);
}
/**
* Sync meeting participants: eligible users for this event's team.
* Eligible = parents with children in team + coaches (excluding admin ID 1).
*/
public function syncMeetingParticipants(int $setByUserId): void
{
$teamId = $this->team_id;
// Users with active children in this team
$parentUserIds = DB::table('parent_player')
->join('players', 'parent_player.player_id', '=', 'players.id')
->where('players.team_id', $teamId)
->whereNull('players.deleted_at')
->where('players.is_active', true)
->join('users', 'parent_player.parent_id', '=', 'users.id')
->whereNull('users.deleted_at')
->where('users.is_active', true)
->pluck('parent_player.parent_id')
->unique();
// Coaches — nur die dem Team zugeordneten
$coachIds = User::where('role', UserRole::Coach->value)
->where('is_active', true)
->whereNull('deleted_at')
->whereHas('coachTeams', fn ($q) => $q->where('teams.id', $teamId))
->pluck('id');
// Admins aus Meeting-Teilnehmern ausschließen (rollenbasiert statt ID-basiert, V12)
$adminIds = User::where('role', UserRole::Admin)->pluck('id');
$eligibleUserIds = $parentUserIds->merge($coachIds)->unique()->diff($adminIds);
$existingUserIds = $this->participants()->whereNotNull('user_id')->pluck('user_id');
$missingUserIds = $eligibleUserIds->diff($existingUserIds);
if ($missingUserIds->isEmpty()) {
return;
}
$now = now();
$records = $missingUserIds->map(fn ($userId) => [
'event_id' => $this->id,
'player_id' => null,
'user_id' => $userId,
'status' => ParticipantStatus::Unknown->value,
'set_by_user_id' => $setByUserId,
'created_at' => $now,
'updated_at' => $now,
])->toArray();
$this->participants()->insert($records);
}
/**
* Sync participants for all future events of a team.
*/
public static function syncParticipantsForTeam(int $teamId, int $userId): void
{
$futureEvents = static::where('team_id', $teamId)
->where('start_at', '>=', now())
->get();
foreach ($futureEvents as $event) {
$event->syncParticipants($userId);
}
}
public function scopePublished($query)
{
return $query->whereIn('status', [EventStatus::Published, EventStatus::Cancelled]);
}
public function scopeUpcoming($query)
{
return $query->where('start_at', '>=', now())->orderBy('start_at');
}
public function scopeForTeam($query, int $teamId)
{
return $query->where('team_id', $teamId);
}
}

35
app/Models/EventCatering.php Executable file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Enums\CateringStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventCatering extends Model
{
protected $table = 'event_catering';
protected $fillable = [
'event_id',
'status',
'note',
];
protected function casts(): array
{
return [
'status' => CateringStatus::class,
];
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
}

56
app/Models/EventParticipant.php Executable file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\ParticipantStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventParticipant extends Model
{
protected $fillable = [
'event_id',
'player_id',
'user_id',
'status',
'note',
'responded_at',
];
protected function casts(): array
{
return [
'status' => ParticipantStatus::class,
'responded_at' => 'datetime',
];
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function player(): BelongsTo
{
return $this->belongsTo(Player::class)->withTrashed();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
public function setByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'set_by_user_id')->withTrashed();
}
public function participantName(): string
{
if ($this->user_id) {
return $this->user?->name ?? __('ui.unknown');
}
return $this->player?->full_name ?? __('ui.unknown');
}
}

26
app/Models/EventTimekeeper.php Executable file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use App\Enums\CateringStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventTimekeeper extends Model
{
protected $fillable = ['event_id', 'status'];
protected $casts = [
'status' => CateringStatus::class,
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class)->withTrashed();
}
}

34
app/Models/Faq.php Executable file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Faq extends Model
{
protected $table = 'faq';
protected $fillable = [
'title',
'category',
'content_html',
'sort_order',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by')->withTrashed();
}
public function events(): BelongsToMany
{
return $this->belongsToMany(Event::class, 'event_faq');
}
}

115
app/Models/File.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\Storage;
class File extends Model
{
protected $fillable = [
'file_category_id',
'original_name',
'mime_type',
'size',
];
protected function casts(): array
{
return [
'size' => 'integer',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(FileCategory::class, 'file_category_id');
}
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by')->withTrashed();
}
public function events(): BelongsToMany
{
return $this->belongsToMany(Event::class, 'event_file')->withPivot('created_at');
}
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class, 'team_file')->withPivot('created_at');
}
public function getStoragePath(): string
{
return 'files/' . $this->stored_name;
}
public function isImage(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
public function isPdf(): bool
{
return $this->mime_type === 'application/pdf';
}
public function isHtml(): bool
{
return $this->mime_type === 'text/html';
}
public function humanSize(): string
{
$bytes = $this->size;
if ($bytes >= 1048576) {
return round($bytes / 1048576, 1) . ' MB';
}
return round($bytes / 1024) . ' KB';
}
public function iconType(): string
{
return match (true) {
str_contains($this->mime_type, 'pdf') => 'pdf',
str_contains($this->mime_type, 'wordprocessingml') => 'word',
str_contains($this->mime_type, 'spreadsheetml') => 'excel',
$this->isImage() => 'image',
$this->isHtml() => 'html',
default => 'file',
};
}
public function extension(): string
{
return pathinfo($this->original_name, PATHINFO_EXTENSION);
}
public function previewData(): array
{
$hasPreview = $this->isImage() || $this->isPdf();
return [
'name' => $this->original_name,
'category' => $this->category->name ?? '',
'size' => $this->humanSize(),
'downloadUrl' => route('files.download', $this),
'previewUrl' => $hasPreview ? route('files.preview', $this) : null,
'isImage' => $this->isImage(),
'isPdf' => $this->isPdf(),
'isHtml' => $this->isHtml(),
'iconBg' => match ($this->iconType()) {
'pdf' => 'bg-red-100 text-red-600',
'word' => 'bg-blue-100 text-blue-600',
'excel' => 'bg-green-100 text-green-600',
'image' => 'bg-purple-100 text-purple-600',
'html' => 'bg-orange-100 text-orange-600',
default => 'bg-gray-100 text-gray-600',
},
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class FileCategory extends Model
{
protected $fillable = [
'name',
'slug',
'sort_order',
'is_active',
];
protected function casts(): array
{
return [
'sort_order' => 'integer',
'is_active' => 'boolean',
];
}
protected static function booted(): void
{
static::creating(function (FileCategory $category) {
if (empty($category->slug)) {
$category->slug = Str::slug($category->name);
}
});
}
public function files(): HasMany
{
return $this->hasMany(File::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('name');
}
}

52
app/Models/Invitation.php Executable file
View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Invitation extends Model
{
public $timestamps = false;
protected $fillable = [
'email',
'expires_at',
'created_at',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
'created_at' => 'datetime',
];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
public function players(): BelongsToMany
{
return $this->belongsToMany(Player::class, 'invitation_players');
}
public function isValid(): bool
{
return $this->accepted_at === null && $this->expires_at->isFuture();
}
public function isAccepted(): bool
{
return $this->accepted_at !== null;
}
public function scopeValid($query)
{
return $query->whereNull('accepted_at')->where('expires_at', '>', now());
}
}

10
app/Models/Location.php Executable file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Location extends Model
{
protected $fillable = ['name', 'address_text', 'location_lat', 'location_lng'];
}

77
app/Models/Player.php Executable file
View File

@@ -0,0 +1,77 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Player extends Model
{
use SoftDeletes;
protected $fillable = [
'team_id',
'first_name',
'last_name',
'birth_year',
'jersey_number',
'is_active',
'photo_permission',
'notes',
'profile_picture',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'photo_permission' => 'boolean',
];
}
public function getFullNameAttribute(): string
{
return "{$this->first_name} {$this->last_name}";
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function parents(): BelongsToMany
{
return $this->belongsToMany(User::class, 'parent_player', 'player_id', 'parent_id')
->withPivot('relationship_label', 'created_at');
}
public function participations(): HasMany
{
return $this->hasMany(EventParticipant::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function getAvatarUrl(): ?string
{
if ($this->profile_picture) {
return asset('storage/' . $this->profile_picture);
}
return null;
}
public function getInitials(): string
{
return mb_strtoupper(mb_substr($this->first_name, 0, 1) . mb_substr($this->last_name, 0, 1));
}
public function isRestorable(): bool
{
return $this->trashed() && $this->deleted_at->diffInDays(now()) < 7;
}
}

47
app/Models/Setting.php Executable file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class Setting extends Model
{
protected $fillable = ['label', 'type', 'value'];
public static function get(string $key, ?string $default = null): ?string
{
return Cache::remember("setting.{$key}", 3600, function () use ($key, $default) {
return static::where('key', $key)->value('value') ?? $default;
});
}
public static function set(string $key, ?string $value): void
{
static::where('key', $key)->update(['value' => $value]);
Cache::forget("setting.{$key}");
}
public static function clearCache(): void
{
$keys = static::pluck('key');
foreach ($keys as $key) {
Cache::forget("setting.{$key}");
}
}
/**
* Prüft ob ein Feature für den gegebenen User sichtbar ist.
* Admin sieht immer alles.
*/
public static function isFeatureVisibleFor(string $feature, User $user): bool
{
if ($user->isAdmin()) {
return true;
}
$key = "visibility_{$feature}_{$user->role->value}";
return static::get($key, '1') === '1';
}
}

67
app/Models/Team.php Executable file
View File

@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class Team extends Model
{
protected $fillable = [
'name',
'year_group',
'is_active',
'notes',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
public function players(): HasMany
{
return $this->hasMany(Player::class);
}
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
public function activePlayers(): HasMany
{
return $this->hasMany(Player::class)->where('is_active', true);
}
public function coaches(): BelongsToMany
{
return $this->belongsToMany(User::class, 'team_user')
->withPivot('created_at');
}
public function files(): BelongsToMany
{
return $this->belongsToMany(File::class, 'team_file')
->withPivot('created_at');
}
public function parentReps(): Collection
{
return User::where('role', UserRole::ParentRep)
->where('is_active', true)
->whereHas('children', fn ($q) => $q->where('team_id', $this->id))
->orderBy('name')
->get();
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

202
app/Models/User.php Executable file
View File

@@ -0,0 +1,202 @@
<?php
namespace App\Models;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Notifications\Notifiable;
use App\Notifications\ResetPasswordNotification;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Collection;
class User extends Authenticatable
{
use HasFactory, Notifiable, SoftDeletes;
protected $fillable = [
'name',
'email',
'phone',
'password',
'locale',
'profile_picture',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role' => UserRole::class,
'is_active' => 'boolean',
'last_login_at' => 'datetime',
'dsgvo_accepted_at' => 'datetime',
'dsgvo_notice_accepted_at' => 'datetime',
];
}
public function sendPasswordResetNotification($token): void
{
$this->notify(new ResetPasswordNotification($token));
}
public function isAdmin(): bool
{
return $this->role === UserRole::Admin;
}
public function isCoach(): bool
{
return $this->role === UserRole::Coach;
}
public function isParentRep(): bool
{
return $this->role === UserRole::ParentRep;
}
public function isStaff(): bool
{
return in_array($this->role, [UserRole::Admin, UserRole::Coach]);
}
public function canAccessAdminPanel(): bool
{
return $this->isStaff() || $this->isParentRep();
}
public function canViewActivityLog(): bool
{
return $this->id === 1 && $this->isAdmin();
}
public function children(): BelongsToMany
{
return $this->belongsToMany(Player::class, 'parent_player', 'parent_id', 'player_id')
->withPivot('relationship_label', 'created_at');
}
/**
* Team-IDs, auf die der User Zugriff hat (über seine Kinder).
* Direkte DB-Query ohne Model-Hydration.
*/
public function accessibleTeamIds(): Collection
{
return $this->children()->distinct()->pluck('players.team_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function caterings(): HasMany
{
return $this->hasMany(EventCatering::class);
}
public function createdInvitations(): HasMany
{
return $this->hasMany(Invitation::class, 'created_by');
}
public function coachTeams(): BelongsToMany
{
return $this->belongsToMany(Team::class, 'team_user')
->withPivot('created_at');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function getAvatarUrl(): ?string
{
if ($this->profile_picture) {
return asset('storage/' . $this->profile_picture);
}
return null;
}
public function getInitials(): string
{
$parts = explode(' ', trim($this->name));
if (count($parts) >= 2) {
return mb_strtoupper(mb_substr($parts[0], 0, 1) . mb_substr(end($parts), 0, 1));
}
return mb_strtoupper(mb_substr($this->name, 0, 2));
}
public function isRestorable(): bool
{
return $this->trashed() && $this->deleted_at->diffInDays(now()) < 7;
}
public function isDsgvoRestricted(): bool
{
if ($this->role !== UserRole::User) {
return false;
}
return !$this->isDsgvoConfirmed();
}
public function needsDsgvoBanner(): bool
{
if ($this->role !== UserRole::User) {
return false;
}
return !$this->isDsgvoConfirmed();
}
public function dsgvoBannerState(): ?string
{
if ($this->role !== UserRole::User) {
return null;
}
if ($this->dsgvo_consent_file === null) {
return 'upload_required';
}
if ($this->dsgvo_accepted_at === null) {
return 'pending_confirmation';
}
return null;
}
public function hasDsgvoConsent(): bool
{
return $this->dsgvo_consent_file !== null;
}
public function isDsgvoConfirmed(): bool
{
return $this->dsgvo_accepted_at !== null;
}
public function dsgvoAcceptedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'dsgvo_accepted_by')->withTrashed();
}
public function getOrphanedChildren(): Collection
{
return $this->children()
->withCount('parents')
->get()
->filter(fn (Player $child) => $child->parents_count <= 1);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use App\Models\Setting;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPasswordNotification extends ResetPassword
{
public function toMail($notifiable): MailMessage
{
$url = $this->resetUrl($notifiable);
$appName = config('app.name');
$locale = $notifiable->locale ?? app()->getLocale();
// Versuche den vom Admin angepassten E-Mail-Text zu laden
$customBody = Setting::get('password_reset_email_' . $locale)
?: Setting::get('password_reset_email_de');
// Bereinige HTML-Tags für die E-Mail (einfacher Text aus Rich-Text)
if ($customBody && strip_tags($customBody) !== '') {
$plainBody = strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>'], "\n", $customBody));
$plainBody = str_replace(
['{name}', '{app_name}', '{link}'],
[$notifiable->name, $appName, $url],
$plainBody
);
$lines = array_filter(array_map('trim', explode("\n", $plainBody)));
$mail = (new MailMessage)
->subject(__('passwords.reset_subject', ['app' => $appName], $locale));
foreach ($lines as $line) {
$mail->line($line);
}
return $mail->action(__('auth_ui.reset_password_button', [], $locale), $url);
}
// Fallback: Standard-Laravel-Template
return (new MailMessage)
->subject(__('passwords.reset_subject', ['app' => $appName], $locale))
->greeting(__('passwords.reset_greeting', ['name' => $notifiable->name], $locale))
->line(__('passwords.reset_line1', [], $locale))
->action(__('auth_ui.reset_password_button', [], $locale), $url)
->line(__('passwords.reset_line2', ['count' => config('auth.passwords.users.expire')], $locale))
->line(__('passwords.reset_line3', [], $locale));
}
}

19
app/Policies/CateringPolicy.php Executable file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\EventCatering;
use App\Models\User;
class CateringPolicy
{
public function update(User $user, EventCatering $catering): bool
{
return $user->id === $catering->user_id || $user->isAdmin();
}
public function create(User $user): bool
{
return true;
}
}

19
app/Policies/CommentPolicy.php Executable file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\Comment;
use App\Models\User;
class CommentPolicy
{
public function create(User $user): bool
{
return true;
}
public function delete(User $user, Comment $comment): bool
{
return $user->isAdmin();
}
}

39
app/Policies/EventPolicy.php Executable file
View File

@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Enums\EventStatus;
use App\Models\Event;
use App\Models\User;
class EventPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Event $event): bool
{
if ($event->status === EventStatus::Draft) {
return $user->isAdmin();
}
return true;
}
public function create(User $user): bool
{
return $user->isAdmin();
}
public function update(User $user, Event $event): bool
{
return $user->isAdmin();
}
public function delete(User $user, Event $event): bool
{
return $user->isAdmin();
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Policies;
use App\Models\EventParticipant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ParticipantPolicy
{
public function update(User $user, EventParticipant $participant): bool
{
if ($user->isAdmin()) {
return true;
}
return DB::table('parent_player')
->where('parent_id', $user->id)
->where('player_id', $participant->player_id)
->exists();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\EventTimekeeper;
use App\Models\User;
class TimekeeperPolicy
{
public function update(User $user, EventTimekeeper $timekeeper): bool
{
return $user->id === $timekeeper->user_id || $user->isAdmin();
}
public function create(User $user): bool
{
return true;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Providers;
use App\Services\GeocodingService;
use App\Services\HtmlSanitizerService;
use App\Services\InvitationService;
use App\Services\SupportApiService;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(HtmlSanitizerService::class);
$this->app->singleton(GeocodingService::class);
$this->app->singleton(InvitationService::class);
$this->app->singleton(SupportApiService::class);
// During installation: ensure .env exists, APP_KEY is set,
// and session/cache use file driver (database may not exist yet).
// This must happen in register() — before middleware and any cache access.
if (! file_exists(storage_path('installed'))) {
$envPath = base_path('.env');
if (! file_exists($envPath) && file_exists(base_path('.env.example'))) {
copy(base_path('.env.example'), $envPath);
}
if (file_exists($envPath) && empty(config('app.key'))) {
$key = 'base64:'.base64_encode(random_bytes(32));
$envContent = file_get_contents($envPath);
$envContent = preg_replace('/^APP_KEY=.*$/m', 'APP_KEY='.$key, $envContent);
file_put_contents($envPath, $envContent);
config(['app.key' => $key]);
}
// Session und Cache auf Datei-basiert umschalten.
// Die .env hat SESSION_DRIVER=database und CACHE_STORE=database,
// aber vor der Installation existiert die Datenbank noch nicht.
config([
'session.driver' => 'file',
'cache.default' => 'file',
'session.cookie' => 'handball_installer_session',
'session.encrypt' => false, // Verschluesselung braucht stabilen Key; bei Erstinstall unnoetig
]);
}
}
public function boot(): void
{
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
});
RateLimiter::for('registration', function (Request $request) {
return Limit::perHour(5)->by($request->ip());
});
RateLimiter::for('user-actions', function (Request $request) {
return Limit::perMinute(30)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('geocoding', function (Request $request) {
return Limit::perMinute(30)->by($request->ip());
});
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class GeocodingService
{
/** Erlaubte Geocoding-Hosts (SSRF-Schutz) */
private const ALLOWED_HOSTS = [
'nominatim.openstreetmap.org',
'photon.komoot.io',
];
public function search(string $query): array
{
$baseUrl = config('nominatim.base_url');
// SSRF-Schutz: Nur erlaubte Hosts und HTTPS
$parsedHost = parse_url($baseUrl, PHP_URL_HOST);
if (!$parsedHost || !in_array($parsedHost, self::ALLOWED_HOSTS)) {
return [];
}
if (parse_url($baseUrl, PHP_URL_SCHEME) !== 'https') {
return [];
}
$cacheKey = 'geocode:' . hash('sha256', mb_strtolower(trim($query)));
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$response = Http::withHeaders([
'User-Agent' => config('nominatim.user_agent'),
])->timeout(5)->get($baseUrl . '/search', [
'q' => $query,
'format' => 'json',
'addressdetails' => 1,
'namedetails' => 1,
'limit' => 5,
'countrycodes' => 'de,at,ch',
'accept-language' => 'de',
]);
// Fehlerhafte Responses nicht cachen (V20)
if ($response->failed()) {
return [];
}
$results = collect($response->json())->map(function ($item) {
$addr = $item['address'] ?? [];
// Structured address from components
$street = trim(($addr['road'] ?? '') . ' ' . ($addr['house_number'] ?? ''));
$postcode = $addr['postcode'] ?? '';
$city = $addr['city'] ?? $addr['town'] ?? $addr['village'] ?? $addr['municipality'] ?? '';
$name = $item['namedetails']['name'] ?? '';
// Build formatted address line
$parts = array_filter([$street, implode(' ', array_filter([$postcode, $city]))]);
$formatted = implode(', ', $parts);
return [
'display_name' => $item['display_name'],
'formatted_address' => $formatted ?: $item['display_name'],
'name' => $name,
'street' => $street,
'postcode' => $postcode,
'city' => $city,
'lat' => $item['lat'],
'lon' => $item['lon'],
'type' => $item['type'] ?? '',
];
})->toArray();
Cache::put($cacheKey, $results, 86400);
return $results;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Services;
use HTMLPurifier;
use HTMLPurifier_Config;
class HtmlSanitizerService
{
private HTMLPurifier $purifier;
public function __construct()
{
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,br,strong,b,em,i,u,ul,ol,li,a[href|target],h2[id],h3[id],h4[id],blockquote,span[style]');
$config->set('CSS.AllowedProperties', 'color,background-color');
$config->set('HTML.TargetBlank', true);
$config->set('AutoFormat.RemoveEmpty', true);
// DOM-Clobbering-Schutz: IDs in User-Content prefixen (V18)
$config->set('Attr.IDPrefix', 'uc-');
$config->set('Cache.SerializerPath', storage_path('app/purifier'));
$this->purifier = new HTMLPurifier($config);
}
public function sanitize(string $dirtyHtml): string
{
return $this->purifier->purify($dirtyHtml);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Services;
use App\Enums\UserRole;
use App\Models\Invitation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class InvitationService
{
public function createInvitation(array $data, User $admin): Invitation
{
$rawToken = bin2hex(random_bytes(32));
$invitation = new Invitation([
'email' => $data['email'] ?? null,
'expires_at' => now()->addDays((int) ($data['expires_in_days'] ?? 7)),
'created_at' => now(),
]);
// Token gehasht speichern — Klartext nur in der URL (V05)
$invitation->token = hash('sha256', $rawToken);
$invitation->created_by = $admin->id;
$invitation->save();
if (!empty($data['player_ids'])) {
$invitation->players()->attach($data['player_ids']);
}
// raw_token für die URL-Generierung bereitstellen (nicht persistiert)
$invitation->raw_token = $rawToken;
return $invitation;
}
public function redeemInvitation(Invitation $invitation, array $userData): User
{
return DB::transaction(function () use ($invitation, $userData) {
$user = User::create([
'name' => $userData['name'],
'email' => $userData['email'],
'password' => $userData['password'],
]);
$user->is_active = true;
$user->role = UserRole::User;
$user->save();
// Eltern-Kind-Zuordnungen aus der Einladung übernehmen
foreach ($invitation->players as $player) {
$user->children()->attach($player->id, [
'created_at' => now(),
]);
}
$invitation->accepted_at = now();
$invitation->save();
return $user;
});
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SupportApiService
{
private ?array $installedData = null;
// ─── Registration ────────────────────────────────────
public function register(array $data): ?array
{
try {
$response = $this->httpClient()
->post('/register', $data);
if ($response->successful()) {
$result = $response->json();
$this->saveCredentials(
$result['installation_id'] ?? '',
$result['api_token'] ?? ''
);
return $result;
}
return null;
} catch (\Exception $e) {
Log::warning('Support API registration failed: ' . $e->getMessage());
return null;
}
}
public function isRegistered(): bool
{
$data = $this->readInstalled();
return !empty($data['installation_id']) && !empty($data['api_token']);
}
// ─── License ─────────────────────────────────────────
public function validateLicense(string $key): ?array
{
try {
$response = $this->authenticatedClient()
->post('/license/validate', ['license_key' => $key]);
if ($response->successful()) {
$result = $response->json();
Cache::put('support.license_valid', $result['valid'] ?? false, 86400);
return $result;
}
return null;
} catch (\Exception $e) {
Log::warning('License validation failed: ' . $e->getMessage());
return null;
}
}
// ─── Updates ─────────────────────────────────────────
public function checkForUpdate(bool $force = false): ?array
{
$cacheKey = 'support.update_check';
if (!$force && Cache::has($cacheKey)) {
return Cache::get($cacheKey);
}
try {
$params = [
'current_version' => config('app.version'),
'app_name' => \App\Models\Setting::get('app_name', config('app.name')),
];
$logoUrl = $this->getLogoUrl();
if ($logoUrl) {
$params['logo_url'] = $logoUrl;
}
$response = $this->authenticatedClient()
->get('/version/check', $params);
if ($response->successful()) {
$result = $response->json();
Cache::put($cacheKey, $result, 86400);
return $result;
}
return null;
} catch (\Exception $e) {
Log::warning('Update check failed: ' . $e->getMessage());
return null;
}
}
public function hasUpdate(): bool
{
$cached = Cache::get('support.update_check');
if (!$cached) {
return false;
}
return version_compare($cached['latest_version'] ?? '0.0.0', config('app.version'), '>');
}
// ─── Tickets ─────────────────────────────────────────
public function getTickets(): ?array
{
try {
$response = $this->authenticatedClient()->get('/tickets');
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
Log::warning('Ticket list fetch failed: ' . $e->getMessage());
return null;
}
}
public function getTicket(int $id): ?array
{
try {
$response = $this->authenticatedClient()->get("/tickets/{$id}");
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
Log::warning("Ticket #{$id} fetch failed: " . $e->getMessage());
return null;
}
}
public function createTicket(array $data): ?array
{
try {
$response = $this->authenticatedClient()->post('/tickets', $data);
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
Log::warning('Ticket creation failed: ' . $e->getMessage());
return null;
}
}
public function replyToTicket(int $id, array $data): ?array
{
try {
$response = $this->authenticatedClient()
->post("/tickets/{$id}/messages", $data);
if ($response->successful()) {
return $response->json();
}
return null;
} catch (\Exception $e) {
Log::warning("Ticket #{$id} reply failed: " . $e->getMessage());
return null;
}
}
// ─── System Info ─────────────────────────────────────
public function getSystemInfo(): array
{
return [
'app_version' => config('app.version'),
'php_version' => PHP_VERSION,
'laravel_version' => app()->version(),
'db_driver' => config('database.default'),
'locale' => app()->getLocale(),
'os' => PHP_OS,
];
}
public function getLogoUrl(): ?string
{
$favicon = \App\Models\Setting::get('app_favicon');
if ($favicon) {
return rtrim(config('app.url'), '/') . '/storage/' . $favicon;
}
return null;
}
// ─── Storage/Installed Access ────────────────────────
public function readInstalled(): array
{
if ($this->installedData !== null) {
return $this->installedData;
}
$path = storage_path('installed');
if (!file_exists($path)) {
return $this->installedData = [];
}
$data = json_decode(file_get_contents($path), true);
return $this->installedData = is_array($data) ? $data : [];
}
// ─── Private Helpers ─────────────────────────────────
private function httpClient(): \Illuminate\Http\Client\PendingRequest
{
$apiUrl = config('support.api_url');
// SSRF-Schutz: Nur HTTPS und keine privaten IPs (T06)
$parsed = parse_url($apiUrl);
$scheme = $parsed['scheme'] ?? '';
$host = $parsed['host'] ?? '';
if ($scheme !== 'https') {
throw new \RuntimeException('Support API URL must use HTTPS.');
}
$ip = gethostbyname($host);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new \RuntimeException('Support API URL must not resolve to a private/reserved IP.');
}
// DNS-Rebinding verhindern: aufgelöste IP direkt verwenden (V07)
$resolvedUrl = str_replace($host, $ip, $apiUrl);
return Http::baseUrl($resolvedUrl)
->timeout(config('support.timeout', 10))
->connectTimeout(config('support.connect_timeout', 5))
->withHeaders(['Accept' => 'application/json', 'Host' => $host]);
}
private function authenticatedClient(): \Illuminate\Http\Client\PendingRequest
{
$token = $this->readInstalled()['api_token'] ?? '';
return $this->httpClient()->withToken($token);
}
private function saveCredentials(string $installationId, string $apiToken): void
{
$path = storage_path('installed');
$data = $this->readInstalled();
$data['installation_id'] = $installationId;
$data['api_token'] = $apiToken;
$data['registered_at'] = now()->toIso8601String();
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));
chmod($path, 0600);
// Reset memoized data
$this->installedData = $data;
}
}

18
artisan Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

72
bootstrap/app.php Executable file
View File

@@ -0,0 +1,72 @@
<?php
use App\Http\Middleware\ActiveUserMiddleware;
use App\Http\Middleware\AdminMiddleware;
use App\Http\Middleware\AdminOnlyMiddleware;
use App\Http\Middleware\DsgvoConsentMiddleware;
use App\Http\Middleware\InstallerMiddleware;
use App\Http\Middleware\SecurityHeadersMiddleware;
use App\Http\Middleware\SetLocaleMiddleware;
use App\Http\Middleware\StaffMiddleware;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withProviders()
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'admin' => AdminMiddleware::class,
'admin-only' => AdminOnlyMiddleware::class,
'staff' => StaffMiddleware::class,
'active' => ActiveUserMiddleware::class,
'dsgvo' => DsgvoConsentMiddleware::class,
]);
// InstallerMiddleware NACH StartSession (nicht prepend), damit
// das Setup-Token in der Session gespeichert werden kann.
// AppServiceProvider::register() setzt session.driver='file'
// vor der Installation, daher braucht StartSession keine DB.
$middleware->web(append: [
InstallerMiddleware::class,
SetLocaleMiddleware::class,
ActiveUserMiddleware::class,
SecurityHeadersMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// Waehrend der Installation: Fehler ohne View-Engine rendern.
// Verhindert den kaskadierenden "Target class [view]"-Fehler,
// falls ein Provider-Fehler auftritt bevor Views verfuegbar sind.
$exceptions->render(function (\Throwable $e, \Illuminate\Http\Request $request) {
if (file_exists(storage_path('installed'))) {
return null; // Nach Installation: Standard-Handler
}
$msg = htmlspecialchars($e->getMessage());
$file = htmlspecialchars($e->getFile()) . ':' . $e->getLine();
$trace = htmlspecialchars($e->getTraceAsString());
$html = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
. '<title>Installationsfehler</title>'
. '<script src="https://cdn.tailwindcss.com"></script></head>'
. '<body class="min-h-screen bg-gray-100 flex items-center justify-center p-4">'
. '<div class="max-w-2xl w-full bg-white rounded-lg shadow-md p-6">'
. '<h1 class="text-lg font-bold text-red-700 mb-3">Fehler beim Starten</h1>'
. '<div class="bg-red-50 border border-red-200 rounded p-4 mb-4">'
. '<p class="text-sm font-medium text-red-800">' . $msg . '</p>'
. '<p class="text-xs text-red-600 mt-1">Datei: ' . $file . '</p></div>'
. '<details class="mb-4"><summary class="text-sm text-gray-600 cursor-pointer">Stack-Trace anzeigen</summary>'
. '<pre class="mt-2 text-xs bg-gray-50 p-3 rounded overflow-x-auto max-h-64 overflow-y-auto">' . $trace . '</pre></details>'
. '<a href="/" class="inline-block px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700">Erneut versuchen</a>'
. '</div></body></html>';
return new \Illuminate\Http\Response($html, 500);
});
})->create();

2
bootstrap/cache/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

5
bootstrap/providers.php Executable file
View File

@@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

90
composer.json Executable file
View File

@@ -0,0 +1,90 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1",
"ezyang/htmlpurifier": "^4.19",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": [
"laravel/pail"
]
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8969
composer.lock generated Executable file

File diff suppressed because it is too large Load Diff

134
config/app.php Executable file
View File

@@ -0,0 +1,134 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Version
|--------------------------------------------------------------------------
*/
'version' => '1.0.0',
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => env('APP_TIMEZONE', 'Europe/Berlin'),
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

115
config/auth.php Executable file
View File

@@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

117
config/cache.php Executable file
View File

@@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'file'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

183
config/database.php Executable file
View File

@@ -0,0 +1,183 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Executable file
View File

@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Executable file
View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Executable file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

6
config/nominatim.php Executable file
View File

@@ -0,0 +1,6 @@
<?php
return [
'user_agent' => env('NOMINATIM_USER_AGENT', 'HandballApp/1.0'),
'base_url' => env('NOMINATIM_BASE_URL', 'https://nominatim.openstreetmap.org'),
];

129
config/queue.php Executable file
View File

@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

38
config/services.php Executable file
View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

217
config/session.php Executable file
View File

@@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', true),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

7
config/support.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
return [
'api_url' => env('SUPPORT_API_URL', 'https://support.rhino.nrw/api/v1'),
'timeout' => 10,
'connect_timeout' => 5,
];

1
database/.gitignore vendored Executable file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('role', 20)->default('user');
$table->boolean('is_active')->default(true);
$table->timestamp('last_login_at')->nullable();
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('teams', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('year_group', 20)->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('teams');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('players', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->restrictOnDelete();
$table->string('first_name', 100);
$table->string('last_name', 100);
$table->unsignedSmallInteger('birth_year')->nullable();
$table->unsignedSmallInteger('jersey_number')->nullable();
$table->boolean('is_active')->default(true);
$table->boolean('photo_permission')->default(false);
$table->text('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('players');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('parent_player', function (Blueprint $table) {
$table->id();
$table->foreignId('parent_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('player_id')->constrained('players')->cascadeOnDelete();
$table->string('relationship_label', 50)->nullable();
$table->timestamp('created_at')->nullable();
$table->unique(['parent_id', 'player_id']);
});
}
public function down(): void
{
Schema::dropIfExists('parent_player');
}
};

Some files were not shown because too many files have changed in this diff Show More