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