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

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

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

View File

@@ -0,0 +1,32 @@
<?php
namespace Database\Seeders;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Database\Seeder;
class AdminSeeder extends Seeder
{
public function run(): void
{
$email = env('ADMIN_EMAIL');
$password = env('ADMIN_PASSWORD');
if (! $email || ! $password) {
$this->command?->error('ADMIN_EMAIL und ADMIN_PASSWORD müssen in .env gesetzt sein!');
return;
}
User::updateOrCreate(
['email' => $email],
[
'name' => 'Administrator',
'password' => $password,
'role' => UserRole::Admin,
'is_active' => true,
]
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
AdminSeeder::class,
SettingsSeeder::class,
FileCategorySeeder::class,
DemoDataSeeder::class,
]);
}
}

View File

@@ -0,0 +1,708 @@
<?php
namespace Database\Seeders;
use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Enums\UserRole;
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\Faq;
use App\Models\Location;
use App\Models\Player;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class DemoDataSeeder extends Seeder
{
public function run(): void
{
$admin = $this->seedAdmin();
$coach = $this->seedCoach();
$parentRep = $this->seedParentRep();
$team = $this->seedTeam($coach);
$data = $this->playerData();
$parentUsers = $this->seedParentUsers($data);
$players = $this->seedPlayers($data, $team);
$this->seedParentPlayerRelations($data, $players, $parentUsers);
$this->assignParentRepChild($parentRep, $players);
$locations = $this->seedLocations();
$events = $this->seedEvents($team, $admin, $locations);
$this->seedParticipants($events, $players, $admin, $parentUsers);
$this->seedCatering($events, $parentUsers);
$this->seedTimekeepers($events, $parentUsers);
$this->seedComments($events, $admin, $coach, $parentUsers);
$this->seedFaqs($admin);
$this->seedActivityLogs($admin, $coach, $team, $events);
$this->seedSoftDeletedRecords($team);
}
// ─── Admin ─────────────────────────────────────────────
private function seedAdmin(): User
{
// Wenn bereits ein Admin existiert (z.B. durch Installer erstellt), diesen nutzen
$existing = User::where('role', UserRole::Admin)->first();
if ($existing) {
return $existing;
}
return User::updateOrCreate(
['email' => env('ADMIN_EMAIL', 'admin@handball.local')],
[
'name' => 'Charles Xavier',
'password' => env('ADMIN_PASSWORD', 'admin1234'),
'role' => UserRole::Admin,
'is_active' => true,
]
);
}
// ─── Trainer ───────────────────────────────────────────
private function seedCoach(): User
{
return User::firstOrCreate(
['email' => 'trainer@handball.local'],
[
'name' => 'Nick Fury',
'password' => Hash::make('trainer1234'),
'role' => UserRole::Coach,
'is_active' => true,
'phone' => '0171-1234567',
]
);
}
// ─── Elternvertretung ──────────────────────────────────
private function seedParentRep(): User
{
return User::firstOrCreate(
['email' => 'elternvertretung@handball.local'],
[
'name' => 'Peggy Carter',
'password' => Hash::make('eltern1234'),
'role' => UserRole::ParentRep,
'is_active' => true,
'phone' => '0172-9876543',
]
);
}
// ─── Team ──────────────────────────────────────────────
private function seedTeam(User $coach): Team
{
$team = Team::firstOrCreate(
['name' => 'Mannschaft I'],
['year_group' => '2017/2018', 'is_active' => true]
);
// Coach via team_user pivot zuweisen
DB::table('team_user')->updateOrInsert(
['team_id' => $team->id, 'user_id' => $coach->id],
['created_at' => now()]
);
return $team;
}
// ─── Datenstruktur ─────────────────────────────────────
private function playerData(): array
{
// [Kind-Vorname, Nachname, [[Eltern-Vorname, Geschlecht], ...]]
// Mix aus Disney-, Marvel- und DC-Universum
return [
// ── Marvel ──────────────────────────────
['Peter', 'Parker', [['Mary', 'w']]],
['Morgan', 'Stark', [['Tony', 'm'], ['Pepper', 'w']]],
['Cooper', 'Barton', [['Clint', 'm'], ['Laura', 'w']]],
['Kamala', 'Khan', [['Muneeba', 'w']]],
['Cassie', 'Lang', [['Scott', 'm']]],
['Natasha', 'Romanoff', [['Alexei', 'm']]],
['Wanda', 'Maximoff', [['Natalya', 'w']]],
['Loki', 'Odinson', [['Frigga', 'w']]],
['Groot', 'Guardian', [['Rocket', 'm']]],
// ── DC ──────────────────────────────────
['Damian', 'Wayne', [['Bruce', 'm'], ['Talia', 'w']]],
['Jon', 'Kent', [['Clark', 'm'], ['Lois', 'w']]],
['Barbara', 'Gordon', [['James', 'm']]],
['Wally', 'West', [['Iris', 'w']]],
['Arthur', 'Curry', [['Atlanna', 'w']]],
['Diana', 'Prince', [['Hippolyta', 'w']]],
['Oliver', 'Queen', [['Moira', 'w']]],
['Kara', 'Danvers', [['Eliza', 'w']]],
['Garfield', 'Logan', [['Marie', 'w']]],
// ── Disney ──────────────────────────────
['Simba', 'Pride', [['Sarabi', 'w']]],
['Elsa', 'Arendelle', [['Iduna', 'w']]],
['Rapunzel', 'Corona', [['Arianna', 'w']]],
['Moana', 'Motunui', [['Sina', 'w']]],
['Nemo', 'Reef', [['Marlin', 'm']]],
['Dash', 'Parr', [['Helen', 'w']]],
['Raya', 'Kumandra', [['Benja', 'm']]],
['Mirabel', 'Madrigal', [['Julieta', 'w']]],
['Riley', 'Andersen', [['Jill', 'w']]],
];
}
// ─── Eltern-Benutzer ───────────────────────────────────
private function seedParentUsers(array $data): array
{
$hashedPassword = Hash::make('eltern1234');
$parentUsers = [];
$phones = [
'Mary Parker' => '0151-1111111',
'Tony Stark' => '0152-2222222',
'Sarabi Pride' => '0153-3333333',
'Scott Lang' => '0154-4444444',
];
foreach ($data as [$childFirst, $lastName, $parents]) {
foreach ($parents as [$parentFirst, $gender]) {
$fullName = "{$parentFirst} {$lastName}";
if (isset($parentUsers[$fullName])) {
continue;
}
$email = $this->makeEmail($parentFirst, $lastName);
$user = User::firstOrCreate(
['email' => $email],
[
'name' => $fullName,
'password' => $hashedPassword,
'role' => UserRole::User,
'is_active' => true,
'phone' => $phones[$fullName] ?? null,
]
);
$parentUsers[$fullName] = $user;
}
}
return $parentUsers;
}
// ─── Spieler ───────────────────────────────────────────
private function seedPlayers(array $data, Team $team): array
{
$players = [];
$jerseyNr = 1;
foreach ($data as [$childFirst, $lastName, $parents]) {
$player = Player::firstOrCreate(
['first_name' => $childFirst, 'last_name' => $lastName, 'team_id' => $team->id],
[
'birth_year' => $jerseyNr % 2 === 0 ? 2017 : 2018,
'jersey_number' => $jerseyNr,
'is_active' => true,
'photo_permission' => true,
]
);
$players["{$childFirst} {$lastName}"] = $player;
$jerseyNr++;
}
return $players;
}
// ─── Eltern-Kind-Zuordnungen ───────────────────────────
// ACHTUNG: parent_player hat KEIN updated_at → DB::table()->updateOrInsert()
private function seedParentPlayerRelations(array $data, array $players, array $parentUsers): void
{
foreach ($data as [$childFirst, $lastName, $parents]) {
$player = $players["{$childFirst} {$lastName}"];
foreach ($parents as [$parentFirst, $gender]) {
$user = $parentUsers["{$parentFirst} {$lastName}"];
DB::table('parent_player')->updateOrInsert(
['parent_id' => $user->id, 'player_id' => $player->id],
['relationship_label' => $gender === 'm' ? 'Vater' : 'Mutter', 'created_at' => now()]
);
}
}
}
// ─── Elternvertretung Kind zuweisen ────────────────────
private function assignParentRepChild(User $parentRep, array $players): void
{
$firstPlayer = reset($players);
DB::table('parent_player')->updateOrInsert(
['parent_id' => $parentRep->id, 'player_id' => $firstPlayer->id],
['relationship_label' => 'Elternvertretung', 'created_at' => now()]
);
}
// ─── Orte ──────────────────────────────────────────────
private function seedLocations(): array
{
return [
Location::firstOrCreate(
['name' => 'Sporthalle Musterstadt'],
['address_text' => 'Schulstraße 12, 12345 Musterstadt', 'location_lat' => 50.1109, 'location_lng' => 8.6821]
),
Location::firstOrCreate(
['name' => 'Sporthalle Nachbarort'],
['address_text' => 'Am Sportpark 5, 12346 Nachbarort', 'location_lat' => 50.1205, 'location_lng' => 8.7100]
),
Location::firstOrCreate(
['name' => 'Turnierzentrum Landeshauptstadt'],
['address_text' => 'Sportplatzweg 1, 60000 Landeshauptstadt', 'location_lat' => 50.1155, 'location_lng' => 8.6842]
),
];
}
// ─── Events ────────────────────────────────────────────
private function seedEvents(Team $team, User $admin, array $locations): array
{
$events = [];
// 0: Training (vergangen)
$events[] = Event::updateOrCreate(
['title' => 'Training Dienstag', 'team_id' => $team->id, 'start_at' => now()->subDays(3)->setTime(17, 0)],
[
'type' => EventType::Training,
'end_at' => now()->subDays(3)->setTime(18, 30),
'status' => EventStatus::Published,
'location_name' => $locations[0]->name,
'address_text' => $locations[0]->address_text,
'location_lat' => $locations[0]->location_lat,
'location_lng' => $locations[0]->location_lng,
'description_html' => '<p>Reguläres Training. Bitte <strong>Hallenschuhe</strong> und ausreichend Trinken mitbringen.</p>',
'created_by' => $admin->id,
]
);
// 1: Training (Zukunft)
$events[] = Event::updateOrCreate(
['title' => 'Training nächste Woche', 'team_id' => $team->id, 'start_at' => now()->next('Tuesday')->setTime(17, 0)],
[
'type' => EventType::Training,
'end_at' => now()->next('Tuesday')->setTime(18, 30),
'status' => EventStatus::Published,
'location_name' => $locations[0]->name,
'address_text' => $locations[0]->address_text,
'location_lat' => $locations[0]->location_lat,
'location_lng' => $locations[0]->location_lng,
'min_players' => 12,
'min_catering' => 1,
'min_timekeepers' => 1,
'description_html' => '<p>Training mit Schwerpunkt Passspiel. Bitte pünktlich kommen!</p>',
'created_by' => $admin->id,
]
);
// 2: Heimspiel (vergangen, mit Ergebnis)
$events[] = Event::updateOrCreate(
['title' => 'Heimspiel vs. TSV Beispielburg', 'team_id' => $team->id, 'start_at' => now()->subWeek()->previous('Saturday')->setTime(10, 0)],
[
'type' => EventType::HomeGame,
'end_at' => now()->subWeek()->previous('Saturday')->setTime(12, 0),
'status' => EventStatus::Published,
'location_name' => $locations[0]->name,
'address_text' => $locations[0]->address_text,
'location_lat' => $locations[0]->location_lat,
'location_lng' => $locations[0]->location_lng,
'opponent' => 'TSV Beispielburg',
'score_home' => 15,
'score_away' => 12,
'min_players' => 10,
'min_catering' => 2,
'min_timekeepers' => 2,
'description_html' => '<p>Unser erstes Heimspiel der Rückrunde! Bitte <strong>30 Minuten vor Anpfiff</strong> da sein.</p><ul><li>Trikots werden gestellt</li><li>Eltern bitte Kuchen mitbringen</li></ul>',
'created_by' => $admin->id,
]
);
// 3: Auswärtsspiel (Zukunft)
$events[] = Event::updateOrCreate(
['title' => 'Auswärtsspiel beim SC Nachbarort', 'team_id' => $team->id, 'start_at' => now()->addWeeks(2)->next('Saturday')->setTime(11, 0)],
[
'type' => EventType::AwayGame,
'end_at' => now()->addWeeks(2)->next('Saturday')->setTime(13, 0),
'status' => EventStatus::Published,
'location_name' => $locations[1]->name,
'address_text' => $locations[1]->address_text,
'location_lat' => $locations[1]->location_lat,
'location_lng' => $locations[1]->location_lng,
'opponent' => 'SC Nachbarort',
'min_players' => 10,
'description_html' => '<p>Auswärtsspiel — Fahrgemeinschaften bitte abstimmen. Treffpunkt 10:00 am Vereinsheim.</p>',
'created_by' => $admin->id,
]
);
// 4: Turnier (Zukunft)
$events[] = Event::updateOrCreate(
['title' => 'Nikolaus-Turnier Sportverein', 'team_id' => $team->id, 'start_at' => now()->addWeeks(3)->next('Sunday')->setTime(9, 0)],
[
'type' => EventType::Tournament,
'end_at' => now()->addWeeks(3)->next('Sunday')->setTime(16, 0),
'status' => EventStatus::Published,
'location_name' => $locations[2]->name,
'address_text' => $locations[2]->address_text,
'location_lat' => $locations[2]->location_lat,
'location_lng' => $locations[2]->location_lng,
'min_players' => 12,
'min_catering' => 3,
'min_timekeepers' => 2,
'description_html' => '<p>Ganztages-Turnier mit 8 Mannschaften. Bitte <strong>Lunchpaket</strong> einpacken.</p><h3>Zeitplan</h3><ul><li>09:00 Eintreffen</li><li>09:30 Erstes Spiel</li><li>ca. 16:00 Siegerehrung</li></ul>',
'created_by' => $admin->id,
]
);
// 5: Besprechung (Zukunft)
$events[] = Event::updateOrCreate(
['title' => 'Elternabend Rückrunden-Planung', 'team_id' => $team->id, 'start_at' => now()->addWeeks(4)->next('Wednesday')->setTime(19, 30)],
[
'type' => EventType::Meeting,
'end_at' => now()->addWeeks(4)->next('Wednesday')->setTime(21, 0),
'status' => EventStatus::Published,
'location_name' => 'Vereinsheim TV Musterstadt',
'address_text' => 'Vereinsweg 1, 12345 Musterstadt',
'min_players' => 10,
'description_html' => '<p>Themen:</p><ul><li>Rückrunden-Spielplan</li><li>Trikot-Bestellung</li><li>Fahrgemeinschaften</li><li>Eltern-Catering-Plan</li></ul>',
'created_by' => $admin->id,
]
);
// 6: Abgesagtes Training
$events[] = Event::updateOrCreate(
['title' => 'Training Donnerstag (entfällt!)', 'team_id' => $team->id, 'start_at' => now()->next('Thursday')->setTime(17, 0)],
[
'type' => EventType::Training,
'end_at' => now()->next('Thursday')->setTime(18, 30),
'status' => EventStatus::Cancelled,
'location_name' => $locations[0]->name,
'address_text' => $locations[0]->address_text,
'description_html' => '<p>Fällt wegen Hallensperrung aus. Nächstes Training wie gewohnt am Dienstag.</p>',
'created_by' => $admin->id,
]
);
// 7: Entwurf
$events[] = Event::updateOrCreate(
['title' => 'Freundschaftsspiel (in Planung)', 'team_id' => $team->id, 'start_at' => now()->addWeeks(5)->next('Saturday')->setTime(10, 0)],
[
'type' => EventType::HomeGame,
'end_at' => now()->addWeeks(5)->next('Saturday')->setTime(12, 0),
'status' => EventStatus::Draft,
'location_name' => $locations[0]->name,
'address_text' => $locations[0]->address_text,
'location_lat' => $locations[0]->location_lat,
'location_lng' => $locations[0]->location_lng,
'opponent' => 'TBD',
'description_html' => '<p>Freundschaftsspiel — Gegner und Uhrzeit werden noch festgelegt.</p>',
'created_by' => $admin->id,
]
);
return $events;
}
// ─── Teilnehmer ────────────────────────────────────────
// ACHTUNG: set_by_user_id ist NOT NULL → immer explizit setzen
private function seedParticipants(array $events, array $players, User $admin, array $parentUsers): void
{
$parentList = array_values($parentUsers);
$playerList = array_values($players);
// Statusverteilungen pro Event-Index: [Ja, Nein, Rest=Offen]
$distributions = [
0 => [22, 3], // Training vergangen
1 => [15, 5], // Training Zukunft
2 => [18, 4], // Heimspiel
3 => [12, 3], // Auswärtsspiel
4 => [20, 2], // Turnier
6 => [5, 2], // Abgesagt
];
$sampleNotes = [
'Komme direkt aus der Schule',
'Wird eventuell 10 Min. später',
'Bringt eigene Trinkflasche mit',
];
foreach ($distributions as $eventIdx => $dist) {
$event = $events[$eventIdx];
[$yesCount, $noCount] = $dist;
foreach (array_values($playerList) as $i => $player) {
if ($i < $yesCount) {
$status = ParticipantStatus::Yes;
} elseif ($i < $yesCount + $noCount) {
$status = ParticipantStatus::No;
} else {
$status = ParticipantStatus::Unknown;
}
$setByUserId = isset($parentList[$i]) ? $parentList[$i]->id : $admin->id;
$respondedAt = $status !== ParticipantStatus::Unknown ? now()->subHours(rand(1, 72)) : null;
$note = ($status === ParticipantStatus::Yes && $i < count($sampleNotes)) ? $sampleNotes[$i] : null;
$participant = EventParticipant::firstOrNew(
['event_id' => $event->id, 'player_id' => $player->id]
);
$participant->status = $status;
$participant->set_by_user_id = $setByUserId;
$participant->responded_at = $respondedAt;
$participant->note = $note;
$participant->save();
}
}
// Besprechung (Index 5): User-basierte Teilnehmer
$meeting = $events[5];
$meeting->syncMeetingParticipants($admin->id);
$meetingParticipants = EventParticipant::where('event_id', $meeting->id)->get();
foreach ($meetingParticipants->take(10) as $idx => $p) {
$p->status = $idx < 7 ? ParticipantStatus::Yes : ParticipantStatus::No;
$p->set_by_user_id = $p->user_id ?? $admin->id;
$p->responded_at = now()->subHours(rand(1, 24));
$p->save();
}
}
// ─── Catering ──────────────────────────────────────────
// Kein Catering bei AwayGame und Meeting
private function seedCatering(array $events, array $parentUsers): void
{
$p = array_values($parentUsers);
$entries = [
[$events[2]->id, $p[0]->id, CateringStatus::Yes, 'Bringe Marmorkuchen mit'],
[$events[2]->id, $p[1]->id, CateringStatus::Yes, 'Bringe Obst und Wasser mit'],
[$events[2]->id, $p[2]->id, CateringStatus::No, 'Kann leider nicht'],
[$events[1]->id, $p[3]->id, CateringStatus::Yes, 'Belegte Brötchen'],
[$events[1]->id, $p[4]->id, CateringStatus::Unknown, null],
[$events[4]->id, $p[5]->id, CateringStatus::Yes, 'Kuchen und Muffins'],
[$events[4]->id, $p[6]->id, CateringStatus::Yes, 'Getränke'],
[$events[4]->id, $p[7]->id, CateringStatus::No, null],
];
foreach ($entries as [$eventId, $userId, $status, $note]) {
$c = EventCatering::where('event_id', $eventId)->where('user_id', $userId)->first();
if (!$c) {
$c = new EventCatering(['event_id' => $eventId]);
$c->user_id = $userId;
}
$c->status = $status;
$c->note = $note;
$c->save();
}
}
// ─── Zeitnehmer ────────────────────────────────────────
// EventTimekeeper nutzt CateringStatus-Enum
private function seedTimekeepers(array $events, array $parentUsers): void
{
$p = array_values($parentUsers);
$entries = [
[$events[2]->id, $p[10]->id, CateringStatus::Yes],
[$events[2]->id, $p[11]->id, CateringStatus::Yes],
[$events[1]->id, $p[12]->id, CateringStatus::Yes],
[$events[1]->id, $p[13]->id, CateringStatus::No],
[$events[4]->id, $p[14]->id, CateringStatus::Yes],
[$events[4]->id, $p[15]->id, CateringStatus::Unknown],
];
foreach ($entries as [$eventId, $userId, $status]) {
$t = EventTimekeeper::where('event_id', $eventId)->where('user_id', $userId)->first();
if (!$t) {
$t = new EventTimekeeper(['event_id' => $eventId]);
$t->user_id = $userId;
}
$t->status = $status;
$t->save();
}
}
// ─── Kommentare ────────────────────────────────────────
private function seedComments(array $events, User $admin, User $coach, array $parentUsers): void
{
$p = array_values($parentUsers);
// Heimspiel (Index 2): 4 Kommentare
Comment::updateOrCreate(
['event_id' => $events[2]->id, 'user_id' => $p[0]->id, 'body' => 'Können wir Fahrgemeinschaften bilden?'],
['created_at' => now()->subDays(5)->subHours(5)]
);
Comment::updateOrCreate(
['event_id' => $events[2]->id, 'user_id' => $admin->id, 'body' => 'Gute Idee! Bitte untereinander absprechen.'],
['created_at' => now()->subDays(5)->subHours(3)]
);
Comment::updateOrCreate(
['event_id' => $events[2]->id, 'user_id' => $p[3]->id, 'body' => 'Wir können 3 Kinder mitnehmen.'],
['created_at' => now()->subDays(5)->subHours(2)]
);
Comment::updateOrCreate(
['event_id' => $events[2]->id, 'user_id' => $coach->id, 'body' => 'Denkt bitte an die Trikots!'],
['created_at' => now()->subDays(5)->subHour()]
);
// Training Zukunft (Index 1): 2 Kommentare
Comment::updateOrCreate(
['event_id' => $events[1]->id, 'user_id' => $p[5]->id, 'body' => 'Kann jemand mein Kind abholen? Bin auf Dienstreise.'],
['created_at' => now()->subHours(12)]
);
Comment::updateOrCreate(
['event_id' => $events[1]->id, 'user_id' => $p[8]->id, 'body' => 'Klar, kein Problem! Bringe sie mit.'],
['created_at' => now()->subHours(10)]
);
// Turnier (Index 4): 2 Kommentare
Comment::updateOrCreate(
['event_id' => $events[4]->id, 'user_id' => $admin->id, 'body' => 'Zeitplan folgt nächste Woche.'],
['created_at' => now()->subDays(1)]
);
Comment::updateOrCreate(
['event_id' => $events[4]->id, 'user_id' => $p[2]->id, 'body' => 'Gibt es vor Ort Essen zu kaufen oder sollen wir selber was mitbringen?'],
['created_at' => now()->subHours(6)]
);
// Besprechung (Index 5): 1 Kommentar
Comment::updateOrCreate(
['event_id' => $events[5]->id, 'user_id' => $p[1]->id, 'body' => 'Wird es eine Tagesordnung geben?'],
['created_at' => now()->subHours(2)]
);
// 1 gelöschter Kommentar (manuelles Soft-Delete via deleted_at + deleted_by)
$deleted = Comment::updateOrCreate(
['event_id' => $events[2]->id, 'user_id' => $p[15]->id, 'body' => 'Dieser Kommentar wurde von einem Admin entfernt.'],
['created_at' => now()->subDays(6)]
);
if (! $deleted->isDeleted()) {
$deleted->update([
'deleted_at' => now()->subDays(4),
'deleted_by' => $admin->id,
]);
}
}
// ─── FAQs ──────────────────────────────────────────────
private function seedFaqs(User $admin): void
{
$faqs = [
['title' => 'Wie melde ich mein Kind für ein Event an?', 'content_html' => '<p>Gehe auf die Event-Detailseite und klicke bei deinem Kind auf "Zusagen". Du kannst den Status jederzeit ändern.</p>', 'sort_order' => 1],
['title' => 'Wie funktioniert das Catering?', 'content_html' => '<p>Bei Heimspielen und Turnieren wird Catering organisiert. Du kannst auf der Event-Seite angeben, ob du etwas mitbringst und was genau.</p>', 'sort_order' => 2],
['title' => 'Was ist ein Zeitnehmer?', 'content_html' => '<p>Bei Heimspielen werden Zeitnehmer benötigt, die die Spieluhr bedienen. Pro Spiel brauchen wir mindestens 2 Zeitnehmer aus den Reihen der Eltern.</p>', 'sort_order' => 3],
];
foreach ($faqs as $data) {
$existing = Faq::where('title', $data['title'])->first();
if (! $existing) {
$faq = new Faq([
'title' => $data['title'],
'content_html' => $data['content_html'],
'sort_order' => $data['sort_order'],
]);
$faq->created_by = $admin->id;
$faq->save();
}
}
}
// ─── Aktivitätslog ─────────────────────────────────────
// ActivityLog: $timestamps = false, created_at muss explizit gesetzt werden
private function seedActivityLogs(User $admin, User $coach, Team $team, array $events): void
{
$base = now()->subDays(7);
$logs = [
['user_id' => $admin->id, 'action' => 'install', 'description' => 'App wurde installiert', 'ip_address' => '127.0.0.1', 'created_at' => $base],
['user_id' => $admin->id, 'action' => 'login', 'description' => 'Admin hat sich eingeloggt', 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addHour()],
['user_id' => $admin->id, 'action' => 'created', 'model_type' => 'Team', 'model_id' => $team->id, 'description' => "Team '{$team->name}' erstellt", 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addHours(2)],
['user_id' => $admin->id, 'action' => 'created', 'model_type' => 'Event', 'model_id' => $events[0]->id, 'description' => "Event '{$events[0]->title}' erstellt", 'properties' => ['new' => ['title' => $events[0]->title, 'type' => 'training']], 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addHours(3)],
['user_id' => $admin->id, 'action' => 'updated', 'model_type' => 'Event', 'model_id' => $events[2]->id, 'description' => "Event '{$events[2]->title}' bearbeitet", 'properties' => ['old' => ['min_players' => null], 'new' => ['min_players' => 10]], 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addHours(4)],
['user_id' => $admin->id, 'action' => 'updated', 'model_type' => 'Setting', 'description' => 'Einstellungen aktualisiert', 'properties' => ['old' => ['app_name' => 'Handball App'], 'new' => ['app_name' => 'Demo Handball']], 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addDay()],
['user_id' => $coach->id, 'action' => 'login', 'description' => 'Trainer hat sich eingeloggt', 'ip_address' => '192.168.1.50', 'created_at' => $base->copy()->addDays(2)],
['user_id' => $admin->id, 'action' => 'created', 'model_type' => 'Player', 'model_id' => 1, 'description' => 'Spieler erstellt', 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addDays(2)->addHour()],
['user_id' => $admin->id, 'action' => 'updated', 'model_type' => 'User', 'model_id' => $coach->id, 'description' => "Rolle von '{$coach->name}' geändert", 'properties' => ['old' => ['role' => 'user'], 'new' => ['role' => 'coach']], 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addDays(3)],
['user_id' => $admin->id, 'action' => 'updated', 'model_type' => 'Event', 'model_id' => $events[6]->id, 'description' => 'Event abgesagt', 'properties' => ['old' => ['status' => 'published'], 'new' => ['status' => 'cancelled']], 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addDays(4)],
['user_id' => $admin->id, 'action' => 'deleted', 'model_type' => 'User', 'description' => 'Benutzer gelöscht (Soft-Delete)', 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addDays(5)],
['user_id' => $admin->id, 'action' => 'created', 'model_type' => 'File', 'description' => 'Datei "Regelwerk_Handball.pdf" hochgeladen', 'ip_address' => '127.0.0.1', 'created_at' => $base->copy()->addDays(6)],
];
foreach ($logs as $log) {
$exists = ActivityLog::where('description', $log['description'])
->where('created_at', $log['created_at'])
->exists();
if (! $exists) {
ActivityLog::create($log);
}
}
}
// ─── Soft-Deleted Records ──────────────────────────────
private function seedSoftDeletedRecords(Team $team): void
{
// Gelöschter User (3 Tage, im restaurierbaren 7-Tage-Fenster)
$deletedUser = User::withTrashed()->firstOrCreate(
['email' => 'geloescht@handball.local'],
[
'name' => 'Thaddeus Ross',
'password' => Hash::make('geloescht1234'),
'role' => UserRole::User,
'is_active' => true,
]
);
if (! $deletedUser->trashed()) {
$deletedUser->delete();
User::withTrashed()->where('id', $deletedUser->id)
->update(['deleted_at' => now()->subDays(3)]);
}
// Gelöschter Spieler (2 Tage, im restaurierbaren 7-Tage-Fenster)
$deletedPlayer = Player::withTrashed()->firstOrCreate(
['first_name' => 'Bucky', 'last_name' => 'Barnes', 'team_id' => $team->id],
[
'birth_year' => 2017,
'is_active' => true,
'photo_permission' => false,
]
);
if (! $deletedPlayer->trashed()) {
$deletedPlayer->delete();
Player::withTrashed()->where('id', $deletedPlayer->id)
->update(['deleted_at' => now()->subDays(2)]);
}
}
// ─── Helper ────────────────────────────────────────────
private function makeEmail(string $first, string $last): string
{
$map = ['ß' => 'ss', 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue',
'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue'];
$f = mb_strtolower(strtr($first, $map));
$l = mb_strtolower(strtr($last, $map));
return "{$f}.{$l}@handball.local";
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Database\Seeders;
use App\Models\FileCategory;
use Illuminate\Database\Seeder;
class FileCategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
['name' => 'FAQ', 'slug' => 'faq', 'sort_order' => 1],
['name' => 'Regelwerke', 'slug' => 'regelwerke', 'sort_order' => 2],
['name' => 'Catering', 'slug' => 'catering', 'sort_order' => 3],
['name' => 'Zeitnehmer', 'slug' => 'zeitnehmer', 'sort_order' => 4],
['name' => 'Events', 'slug' => 'events', 'sort_order' => 5],
['name' => 'Turniere', 'slug' => 'turniere', 'sort_order' => 6],
['name' => 'Allgemein', 'slug' => 'allgemein', 'sort_order' => 7],
];
foreach ($categories as $cat) {
FileCategory::updateOrCreate(['slug' => $cat['slug']], $cat);
}
}
}

View File

@@ -0,0 +1,290 @@
<?php
namespace Database\Seeders;
use App\Models\Setting;
use Illuminate\Database\Seeder;
class SettingsSeeder extends Seeder
{
public function run(): void
{
$impressum = <<<'HTML'
<h3>Angaben gemäß § 5 TMG</h3>
<p><strong>[Vor- und Nachname]</strong><br>
[Straße und Hausnummer]<br>
[PLZ] [Ort]</p>
<h3>Kontakt</h3>
<p>E-Mail: [deine-email@beispiel.de]<br>
Telefon: [optional Telefonnummer]</p>
<h3>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h3>
<p>[Vor- und Nachname]<br>
[Anschrift wie oben]</p>
<h3>Haftungshinweis</h3>
<p>Diese WebApp ist ein privates, nicht-kommerzielles Projekt zur internen Organisation einer Kinder-Handballmannschaft. Die Inhalte sind nur für registrierte Mitglieder (Eltern und Trainer) bestimmt.</p>
<p>Trotz sorgfältiger Kontrolle übernehmen wir keine Haftung für die Inhalte externer Links. Für den Inhalt verlinkter Seiten sind ausschließlich deren Betreiber verantwortlich.</p>
HTML;
$datenschutz = <<<'HTML'
<h3 id="hinweis"><strong>Wichtiger Hinweis vorab</strong></h3>
<p><strong>Diese WebApp ist eine rein private, nicht-kommerzielle Vereins-Webseite.</strong> Sie dient ausschließlich der internen Koordination einer Kinder-Handballmannschaft also der Organisation von Trainings, Spielen, Turnieren, Catering und Zeitnehmer-Diensten.</p>
<p><strong>Es fließen keinerlei Daten an Dritte ab.</strong> Wir verkaufen, teilen oder übermitteln keine personenbezogenen Daten an externe Unternehmen, Werbetreibende, soziale Netzwerke oder sonstige Dritte. Es gibt kein Tracking, keine Analyse-Tools, keine Werbung und keine versteckten Datenflüsse. Alle Daten verbleiben ausschließlich auf unserem Server und werden nur für den beschriebenen Vereinszweck genutzt.</p>
<h3 id="verantwortlicher"><strong>1. Verantwortlicher</strong></h3>
<p>[Vor- und Nachname]<br>
[Straße und Hausnummer]<br>
[PLZ] [Ort]<br>
E-Mail: [deine-email@beispiel.de]</p>
<h3 id="webapp"><strong>2. Was ist diese WebApp?</strong></h3>
<p>Diese WebApp ist ein geschlossenes, privates System für Eltern und Trainer einer Kinder-Handballmannschaft. Der Zugang erfolgt ausschließlich über persönliche Einladungslinks es gibt keine offene Registrierung und keinen öffentlichen Zugang zu Inhalten. Die App ersetzt klassische Kommunikation über Messenger-Gruppen und bietet eine zentrale Plattform für:</p>
<ul>
<li>Terminplanung (Training, Spiele, Turniere, Besprechungen)</li>
<li>Zu- und Absagen der Spieler durch die Eltern</li>
<li>Catering-Koordination und Zeitnehmer-Organisation</li>
<li>Datei-Ablage (Regelwerke, Turnierinfos, etc.)</li>
<li>Kommentarfunktion für Absprachen</li>
</ul>
<h3 id="daten"><strong>3. Erhobene Daten</strong></h3>
<p>Bei der Nutzung werden folgende personenbezogene Daten verarbeitet nicht mehr und nicht weniger:</p>
<ul>
<li><strong>Registrierungsdaten:</strong> Name, E-Mail-Adresse, Passwort (verschlüsselt gespeichert als kryptographischer Hash das Passwort ist niemals im Klartext einsehbar, auch nicht für Administratoren)</li>
<li><strong>Spielerdaten:</strong> Vorname, Nachname, Geburtsjahr, Trikotnummer, Fotoerlaubnis, Teamzugehörigkeit (durch Administratoren gepflegt)</li>
<li><strong>Profilbilder:</strong> Optional hochgeladene Fotos für Benutzer und Spieler (nur sichtbar für eingeloggte Mitglieder)</li>
<li><strong>Zuordnungsdaten:</strong> Eltern-Kind-Beziehung zwischen Benutzerkonto und Spielern</li>
<li><strong>Veranstaltungsdaten:</strong> Teilnahme-Status (Zusage/Absage), Catering-Status und optionale Notizen, Zeitnehmer-Bereitschaft, Kommentare zu Veranstaltungen</li>
<li><strong>Hochgeladene Dateien:</strong> Dokumente, die Administratoren bereitstellen (PDF, Bilder, etc.)</li>
<li><strong>Technische Daten:</strong> IP-Adresse (in Server-Logs), Zeitpunkt des letzten Logins, gewählte Sprache</li>
</ul>
<h3 id="zweck"><strong>4. Zweck der Verarbeitung</strong></h3>
<p>Die Daten werden <strong>ausschließlich</strong> zu folgenden Zwecken verarbeitet:</p>
<ul>
<li>Verwaltung der Benutzerkonten und Authentifizierung</li>
<li>Koordination von Trainingsterminen, Spielen und sonstigen Veranstaltungen</li>
<li>Erfassung von Zu- und Absagen der Spieler</li>
<li>Organisation der Verpflegung (Catering) und Zeitnehmer-Dienste bei Veranstaltungen</li>
<li>Bereitstellung von Dokumenten und Dateien für die Mannschaft</li>
<li>Kommunikation über die Kommentarfunktion</li>
</ul>
<p>Es findet <strong>keine</strong> Auswertung, kein Profiling und keine automatisierte Entscheidungsfindung statt.</p>
<h3 id="rechtsgrundlage"><strong>5. Rechtsgrundlage</strong></h3>
<p>Die Verarbeitung erfolgt auf Grundlage von <strong>Art. 6 Abs. 1 lit. f DSGVO</strong> (berechtigtes Interesse). Das berechtigte Interesse liegt in der effizienten Organisation der Mannschaftsaktivitäten im Rahmen einer nicht-kommerziellen, vereinsähnlichen Struktur. Sie können dieser Verarbeitung jederzeit widersprechen.</p>
<h3 id="cookies"><strong>6. Cookies und Sitzungsdaten</strong></h3>
<p>Diese WebApp verwendet <strong>ausschließlich ein einziges, technisch notwendiges Session-Cookie</strong>. Dieses Cookie ist für die Anmeldefunktion zwingend erforderlich und enthält keine personenbezogenen Daten lediglich eine zufällige Sitzungs-ID, die beim Schließen des Browsers oder nach Ablauf der Sitzung automatisch gelöscht wird.</p>
<p>Es werden <strong>keine</strong> Tracking-, Analyse- oder Werbe-Cookies eingesetzt. Kein Google Analytics, kein Facebook Pixel, kein Matomo, keine Heatmaps nichts dergleichen. Ein Cookie-Banner ist daher nach aktueller Rechtslage nicht erforderlich (§ 25 Abs. 2 Nr. 2 TDDDG), da ausschließlich technisch unbedingt erforderliche Cookies verwendet werden.</p>
<h3 id="externe-dienste"><strong>7. Externe Dienste und technische Ressourcen</strong></h3>
<p>Zur Darstellung der Webseite werden einige technische Ressourcen (CSS-Stylesheets, JavaScript-Bibliotheken) von externen Servern geladen. Dabei kann Ihre IP-Adresse an diese Anbieter übermittelt werden. <strong>Dies dient ausschließlich der technischen Funktionsfähigkeit es werden dabei keine personenbezogenen Nutzungsdaten erhoben oder ausgewertet.</strong></p>
<p>Im Folgenden sind <strong>alle</strong> externen Dienste aufgeführt, mit denen diese WebApp kommuniziert. Es gibt keine weiteren versteckten Verbindungen.</p>
<p><strong>Content Delivery Networks (CDN):</strong></p>
<ul>
<li><strong>cdn.tailwindcss.com</strong> CSS-Framework für das Layout (Tailwind CSS). Beim Laden der Seite wird ein JavaScript-Compiler von diesem Server geladen, der das Styling generiert. Dabei wird Ihre IP-Adresse übermittelt. Betreiber: Tailwind Labs Inc., USA. Es werden keine Cookies gesetzt und keine Nutzerprofile erstellt.</li>
<li><strong>cdn.jsdelivr.net</strong> JavaScript-Bibliotheken: Alpine.js (reaktive UI-Elemente) und Quill.js (Texteditor im Admin-Bereich). Betreiber: Prospect One (Open-Source-CDN). Alle Dateien werden mit kryptographischer Integritätsprüfung (SRI) geladen eine Manipulation der Dateien auf dem CDN ist somit ausgeschlossen. Es werden keine Cookies gesetzt.</li>
<li><strong>unpkg.com</strong> Kartenbibliothek Leaflet.js (für die Anzeige von Veranstaltungsorten auf einer Karte). Betreiber: Cloudflare/UNPKG. Ebenfalls mit SRI-Integritätsprüfung abgesichert. Es werden keine Cookies gesetzt.</li>
</ul>
<p>Diese CDNs liefern ausschließlich statische Dateien (CSS, JavaScript) aus. <strong>Es fließen keine personenbezogenen Daten an diese Dienste außer der IP-Adresse im Rahmen des technisch notwendigen HTTP-Abrufs.</strong> Die Dateien werden von Ihrem Browser zwischengespeichert (Cache), sodass bei wiederholten Besuchen keine erneuten Abrufe stattfinden.</p>
<p><strong>OpenStreetMap (Kartenanzeige):</strong></p>
<p>Auf Veranstaltungs-Detailseiten wird eine interaktive Karte eingebunden. Dabei werden Kartenbilder (sogenannte „Tiles") von den Servern <em>tile.openstreetmap.org</em> der OpenStreetMap Foundation (OSMF) geladen. Hierbei wird Ihre IP-Adresse an die OSMF übermittelt. OpenStreetMap ist ein freies, nichtkommerzielles Projekt mit Sitz in Großbritannien. Es werden keine Tracking-Cookies gesetzt und keine Nutzerprofile erstellt. Die Karte wird nur auf Veranstaltungs-Detailseiten geladen, nicht auf anderen Seiten. Datenschutzrichtlinie: <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" target="_blank" rel="noopener">osmfoundation.org/wiki/Privacy_Policy</a></p>
<p>Zusätzlich wird auf Veranstaltungs-Detailseiten ein Link zur Routenplanung über <em>openstreetmap.org</em> angeboten. Dieser Link öffnet sich in einem neuen Tab und wird <strong>erst durch Ihren aktiven Klick</strong> aufgerufen es findet kein automatischer Datentransfer statt.</p>
<p><strong>Photon / Komoot (Adress-Autocomplete):</strong></p>
<p>Im Administrationsbereich wird für die Adresssuche bei der Erstellung von Veranstaltungen und Orten der Photon-Geocoding-Dienst genutzt (<em>photon.komoot.io</em>). Dabei wird der eingegebene Suchbegriff zusammen mit Ihrer IP-Adresse an die Server von Komoot GmbH (Potsdam, Deutschland) übermittelt. Photon ist ein <strong>Open-Source-Projekt</strong>, das auf OpenStreetMap-Daten basiert. Es werden keine Cookies gesetzt und keine Nutzerprofile erstellt. Dieser Dienst wird <strong>ausschließlich im Administrationsbereich</strong> und <strong>nur bei aktiver Eingabe</strong> durch einen Administrator aufgerufen normale Benutzer lösen diesen Dienst nicht aus. Datenschutzrichtlinie: <a href="https://www.komoot.com/privacy" target="_blank" rel="noopener">komoot.com/privacy</a></p>
<p><strong>Nominatim (Server-seitige Adresssuche):</strong></p>
<p>Ergänzend zum Photon-Dienst wird im Administrationsbereich der Nominatim-Dienst der OpenStreetMap Foundation zur Adresssuche genutzt. Anders als bei Photon erfolgt dieser Abruf <strong>über unseren Server</strong> (nicht direkt aus Ihrem Browser). Dabei wird nur der Suchbegriff übermittelt, <strong>nicht Ihre IP-Adresse</strong>. Suchergebnisse werden 24 Stunden lang auf unserem Server zwischengespeichert, um unnötige Anfragen zu vermeiden. Auch dieser Dienst wird <strong>nur bei aktiver Eingabe durch einen Administrator</strong> aufgerufen.</p>
<p><strong>Übersicht: Welcher Dienst wird wann aufgerufen?</strong></p>
<ul>
<li><strong>Bei jedem Seitenaufruf:</strong> Tailwind CSS (cdn.tailwindcss.com), Alpine.js (cdn.jsdelivr.net) — nur beim ersten Besuch, danach aus dem Browser-Cache</li>
<li><strong>Nur auf Veranstaltungs-Detailseiten:</strong> Leaflet.js (unpkg.com), OpenStreetMap-Kacheln (tile.openstreetmap.org)</li>
<li><strong>Nur im Admin-Bereich bei Textbearbeitung:</strong> Quill.js (cdn.jsdelivr.net)</li>
<li><strong>Nur im Admin-Bereich bei Adresssuche:</strong> Photon (photon.komoot.io), Nominatim (nominatim.openstreetmap.org)</li>
<li><strong>Nur bei aktivem Klick durch den Benutzer:</strong> OpenStreetMap-Routenplanung (openstreetmap.org)</li>
</ul>
<h3 id="keine-weitergabe"><strong>8. Keine Weitergabe an Dritte</strong></h3>
<p><strong>Wir geben Ihre Daten nicht weiter.</strong> Nicht an Werbepartner, nicht an soziale Netzwerke, nicht an Datenbroker, nicht an andere Vereine und nicht an sonstige Dritte. Die unter Punkt 7 genannten externen Dienste erhalten <strong>ausschließlich Ihre IP-Adresse</strong> im Rahmen technisch notwendiger HTTP-Abrufe keine Namen, keine E-Mail-Adressen, keine Inhalte und keine sonstigen personenbezogenen Daten.</p>
<p>Die einzige Ausnahme wäre eine gesetzliche Verpflichtung zur Herausgabe (z. B. bei einer gerichtlichen Anordnung) dieser Fall ist bei einer internen Vereins-Koordinationsplattform praktisch nicht relevant.</p>
<h3 id="hosting"><strong>9. Hosting und Serverstandort</strong></h3>
<p>Diese WebApp wird auf einem Server in <strong>Deutschland</strong> betrieben (All-Inkl.com, Hauptstraße 68, 02742 Friedersdorf). Alle Ihre Daten (Benutzerkonten, Spielerdaten, Veranstaltungen, Dateien, Kommentare) verbleiben ausschließlich auf diesem Server in Deutschland.</p>
<p>Es findet <strong>kein Transfer</strong> personenbezogener Daten in Drittstaaten statt. Die einzigen Verbindungen zu Servern außerhalb Deutschlands sind die oben genannten CDN-Abrufe (bei denen lediglich Ihre IP-Adresse für den technischen Ladevorgang übermittelt wird) sowie die OpenStreetMap-Kartenkacheln. Bei keinem dieser Abrufe werden personenbezogene Inhalte (Namen, E-Mails, etc.) übertragen.</p>
<h3 id="speicherdauer"><strong>10. Speicherdauer</strong></h3>
<p>Personenbezogene Daten werden gespeichert, solange das Benutzerkonto aktiv ist bzw. solange Ihr Kind in der Mannschaft spielt. Bei Deaktivierung des Kontos durch einen Administrator werden die Daten aufbewahrt, der Zugang aber gesperrt. Eine vollständige Löschung aller Ihrer Daten kann jederzeit beim Verantwortlichen beantragt werden und wird zeitnah umgesetzt.</p>
<h3 id="rechte"><strong>11. Ihre Rechte</strong></h3>
<p>Sie haben gemäß DSGVO folgende Rechte:</p>
<ul>
<li><strong>Auskunft</strong> über Ihre gespeicherten Daten (Art. 15 DSGVO)</li>
<li><strong>Berichtigung</strong> unrichtiger Daten (Art. 16 DSGVO)</li>
<li><strong>Löschung</strong> Ihrer Daten (Art. 17 DSGVO)</li>
<li><strong>Einschränkung</strong> der Verarbeitung (Art. 18 DSGVO)</li>
<li><strong>Datenübertragbarkeit</strong> (Art. 20 DSGVO)</li>
<li><strong>Widerspruch</strong> gegen die Verarbeitung (Art. 21 DSGVO)</li>
</ul>
<p>Zur Ausübung Ihrer Rechte wenden Sie sich bitte formlos an den Verantwortlichen (siehe oben) per E-Mail genügt. Darüber hinaus steht Ihnen ein <strong>Beschwerderecht bei einer Aufsichtsbehörde</strong> zu (Art. 77 DSGVO).</p>
<h3 id="sicherheit"><strong>12. Datensicherheit</strong></h3>
<p>Wir setzen technische und organisatorische Maßnahmen ein, um Ihre Daten zu schützen:</p>
<ul>
<li>Verschlüsselte Übertragung aller Daten (HTTPS/TLS)</li>
<li>Passwörter werden ausschließlich als kryptographische Hashes gespeichert (BCrypt) selbst Administratoren können Ihr Passwort nicht einsehen</li>
<li>Schutz vor Brute-Force-Angriffen durch automatische Ratenbegrenzung</li>
<li>Schutz vor Cross-Site-Scripting (XSS) und Cross-Site-Request-Forgery (CSRF)</li>
<li>Content Security Policy (CSP) zur Einschränkung externer Inhalte nur die oben genannten Quellen sind erlaubt</li>
<li>Subresource Integrity (SRI) für alle CDN-Bibliotheken manipulierte Dateien werden vom Browser blockiert</li>
<li>HTTP Strict Transport Security (HSTS) zur erzwungenen Verschlüsselung</li>
<li>Zugang nur über persönliche Einladungslinks (keine offene Registrierung, keine Suchmaschinen-Indexierung)</li>
<li>Dokumente werden in einem geschützten Verzeichnis gespeichert und sind nur nach Anmeldung zugänglich</li>
<li>Server-Fingerprinting deaktiviert (keine Preisgabe von Software-Versionen)</li>
</ul>
<h3 id="zusammenfassung"><strong>13. Zusammenfassung</strong></h3>
<p>Kurz und knapp: <strong>Diese WebApp ist ein privates Werkzeug zur Mannschafts-Organisation nicht mehr und nicht weniger.</strong> Wir verdienen kein Geld damit, wir sammeln keine Daten zum Verkauf, wir tracken niemanden und wir geben nichts weiter. Ihre Daten gehören Ihnen und werden ausschließlich für den Vereinszweck genutzt.</p>
<p>Die einzigen externen Verbindungen dienen dem Laden von Layout-Bibliotheken und Kartenmaterial dabei wird ausschließlich Ihre IP-Adresse im Rahmen des normalen Internetverkehrs übermittelt, niemals Ihre personenbezogenen Inhalte.</p>
HTML;
$settings = [
[
'key' => 'app_name',
'label' => 'App-Name',
'type' => 'text',
'value' => 'Handball App',
],
[
'key' => 'impressum_html',
'label' => 'Impressum',
'type' => 'html',
'value' => $impressum,
],
[
'key' => 'datenschutz_html',
'label' => 'Datenschutzerklärung',
'type' => 'html',
'value' => $datenschutz,
],
];
foreach ($settings as $setting) {
$existing = Setting::where('key', $setting['key'])->first();
if ($existing) {
// Nur Metadaten aktualisieren, NICHT den Wert überschreiben
$existing->update(['label' => $setting['label'], 'type' => $setting['type']]);
} else {
$this->createSetting($setting);
}
}
// Slogan
$sloganSettings = [
[
'key' => 'app_slogan',
'label' => 'Slogan',
'type' => 'richtext',
'value' => '<p><em>Gemeinsam stark — auf und neben dem Spielfeld</em></p>',
],
[
'key' => 'app_favicon',
'label' => 'Favicon',
'type' => 'text',
'value' => null,
],
];
foreach ($sloganSettings as $setting) {
$existing = Setting::where('key', $setting['key'])->first();
if ($existing) {
$existing->update(['label' => $setting['label'], 'type' => $setting['type']]);
} else {
$this->createSetting($setting);
}
}
// Statistik-Sichtbarkeit
$statsEnabled = Setting::where('key', 'statistics_enabled')->first();
if ($statsEnabled) {
$statsEnabled->update(['label' => 'Statistik aktiviert', 'type' => 'number']);
} else {
$this->createSetting([
'key' => 'statistics_enabled',
'label' => 'Statistik aktiviert',
'type' => 'number',
'value' => '1',
]);
}
// Sichtbarkeits-Einstellungen (pro Feature pro Rolle)
$visibilitySettings = [
['key' => 'visibility_statistics_coach', 'label' => 'Statistik: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_statistics_parent_rep', 'label' => 'Statistik: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_catering_history_coach', 'label' => 'Catering-Verlauf: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_catering_history_parent_rep', 'label' => 'Catering-Verlauf: Elternvertretung', 'type' => 'number', 'value' => '1'],
];
foreach ($visibilitySettings as $setting) {
$existing = Setting::where('key', $setting['key'])->first();
if ($existing) {
$existing->update(['label' => $setting['label'], 'type' => $setting['type']]);
} else {
$this->createSetting($setting);
}
}
// Lizenzschlüssel
$licenseKey = Setting::where('key', 'license_key')->first();
if ($licenseKey) {
$licenseKey->update(['label' => 'Lizenzschlüssel', 'type' => 'text']);
} else {
$this->createSetting([
'key' => 'license_key',
'label' => 'Lizenzschlüssel',
'type' => 'text',
'value' => null,
]);
}
// Event-Defaults für Mindestanforderungen
foreach (['home_game', 'away_game', 'training', 'tournament', 'meeting'] as $type) {
foreach (['players', 'catering', 'timekeepers'] as $field) {
$key = "default_min_{$field}_{$type}";
$existing = Setting::where('key', $key)->first();
if ($existing) {
$existing->update([
'label' => "Default Min. " . ucfirst($field) . " (" . ucfirst($type) . ")",
'type' => 'number',
]);
} else {
Setting::create([
'key' => $key,
'label' => "Default Min. " . ucfirst($field) . " (" . ucfirst($type) . ")",
'type' => 'number',
'value' => null,
]);
}
}
}
}
/**
* Setting erstellen mit expliziter key-Zuweisung (key nicht in $fillable).
*/
private function createSetting(array $data): Setting
{
$key = $data['key'];
unset($data['key']);
$setting = new Setting($data);
$setting->key = $key;
$setting->save();
return $setting;
}
}