diff --git a/src/Koogle.Domain/Interfaces/IDemoResetService.cs b/src/Koogle.Domain/Interfaces/IDemoResetService.cs
new file mode 100644
index 0000000..c33749d
--- /dev/null
+++ b/src/Koogle.Domain/Interfaces/IDemoResetService.cs
@@ -0,0 +1,23 @@
+namespace Koogle.Domain.Interfaces;
+
+///
+/// Service for resetting the Demo club to initial state.
+///
+public interface IDemoResetService
+{
+ ///
+ /// Resets the Demo club to initial state (hard delete + re-seed).
+ ///
+ /// True if reset was successful, false if Demo club not found or not enabled.
+ Task ResetDemoClubAsync(CancellationToken ct = default);
+
+ ///
+ /// Checks if the given club ID is the Demo club.
+ ///
+ bool IsDemoClub(Guid clubId);
+
+ ///
+ /// Gets the Demo club ID if Demo mode is enabled.
+ ///
+ Guid? GetDemoClubId();
+}
diff --git a/src/Koogle.Infrastructure/Data/DemoSeeder.cs b/src/Koogle.Infrastructure/Data/DemoSeeder.cs
new file mode 100644
index 0000000..6432661
--- /dev/null
+++ b/src/Koogle.Infrastructure/Data/DemoSeeder.cs
@@ -0,0 +1,407 @@
+using Koogle.Domain.Entities;
+using Koogle.Domain.Enums;
+using Koogle.Domain.Interfaces;
+using Koogle.Infrastructure.Identity;
+using KoogleApp.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Koogle.Infrastructure.Data;
+
+///
+/// Seeds demo data for the Demo club including user, persons, expenses, and sample days.
+///
+public static class DemoSeeder
+{
+ private static readonly string[] MemberNames =
+ {
+ "Hans Maier", "Klaus Schmidt", "Werner Braun", "Dieter Fischer",
+ "Juergen Weber", "Heinz Mueller", "Rolf Schneider", "Karl Hoffmann"
+ };
+
+ private static readonly string[] GuestNames = { "Stefan Gast", "Thomas Besucher" };
+
+ ///
+ /// Seeds demo user, club membership, persons, expenses, triggers and sample days.
+ ///
+ public static async Task SeedAsync(IServiceProvider services, IConfiguration config, IHostEnvironment env)
+ {
+ var demoEnabled = config.GetValue("Bootstrap:Demo:Enabled");
+ if (!demoEnabled) return;
+
+ var allowSeed = env.IsDevelopment() || config.GetValue("Bootstrap:EnableSeeding");
+ if (!allowSeed) return;
+
+ using var scope = services.CreateScope();
+
+ var userManager = scope.ServiceProvider.GetRequiredService>();
+ var appDb = scope.ServiceProvider.GetRequiredService();
+ var triggerRepo = scope.ServiceProvider.GetRequiredService();
+
+ var demoEmail = config["Bootstrap:Demo:Email"] ?? "demo@koogle.de";
+ var demoPassword = config["Bootstrap:Demo:Password"] ?? "demo123";
+ var demoClubName = config["Bootstrap:Demo:ClubName"] ?? "Demo";
+
+ // 1) Find or create Demo Club (should exist from BootstrapSeeder if DefaultClub.Name == "Demo")
+ var club = await appDb.Clubs.FirstOrDefaultAsync(c => c.Name == demoClubName && !c.IsDeleted);
+ if (club == null)
+ {
+ club = new Club { Name = demoClubName, CreatedAt = DateTime.UtcNow };
+ appDb.Clubs.Add(club);
+ await appDb.SaveChangesAsync();
+ }
+
+ // 2) Create Demo User (NOT SuperAdmin)
+ var demoUser = await userManager.FindByEmailAsync(demoEmail);
+ if (demoUser == null)
+ {
+ demoUser = new ApplicationUser
+ {
+ UserName = demoEmail,
+ Email = demoEmail,
+ EmailConfirmed = true
+ };
+
+ var result = await userManager.CreateAsync(demoUser, demoPassword);
+ if (!result.Succeeded)
+ {
+ throw new InvalidOperationException("Failed to create Demo user: " +
+ string.Join("; ", result.Errors.Select(e => $"{e.Code}:{e.Description}")));
+ }
+ }
+
+ // 3) Create UserProfile for Demo user
+ var profile = await appDb.UserProfiles.FirstOrDefaultAsync(p => p.IdentityUserId == demoUser.Id);
+ if (profile == null)
+ {
+ profile = new UserProfile
+ {
+ IdentityUserId = demoUser.Id,
+ DisplayName = "Demo Benutzer",
+ Locale = "de-DE",
+ TimeZone = "Europe/Berlin",
+ CreatedAt = DateTime.UtcNow
+ };
+ appDb.UserProfiles.Add(profile);
+ await appDb.SaveChangesAsync();
+ }
+
+ // 4) Add Demo user to Demo club
+ var membership = await appDb.UserProfileClubs.FindAsync(profile.Id, club.Id);
+ if (membership == null)
+ {
+ membership = new UserProfileClub
+ {
+ UserProfileId = profile.Id,
+ ClubId = club.Id,
+ IsDefault = true,
+ AssignedAt = DateTime.UtcNow,
+ AssignedById = demoUser.Id
+ };
+ appDb.UserProfileClubs.Add(membership);
+ await appDb.SaveChangesAsync();
+ }
+
+ // 5) Assign ClubAdmin role to Demo user (NOT SuperAdmin!)
+ var roleExists = await appDb.UserProfileClubRoleAssignments.AnyAsync(a =>
+ a.UserProfileId == profile.Id && a.ClubId == club.Id && a.RoleName == "Admin" && !a.IsDeleted);
+
+ if (!roleExists)
+ {
+ appDb.UserProfileClubRoleAssignments.Add(new UserProfileClubRoleAssignment
+ {
+ UserProfileId = profile.Id,
+ ClubId = club.Id,
+ RoleId = Guid.Empty,
+ RoleName = "Admin",
+ AssignedAt = DateTime.UtcNow,
+ AssignedById = demoUser.Id,
+ CreatedAt = DateTime.UtcNow
+ });
+ await appDb.SaveChangesAsync();
+ }
+
+ // 6) Seed demo data if not already present
+ var hasPersons = await appDb.Persons.AnyAsync(p => p.ClubId == club.Id && !p.IsDeleted);
+ if (!hasPersons)
+ {
+ await SeedDemoDataAsync(appDb, triggerRepo, club.Id, demoUser.Id);
+ }
+ }
+
+ ///
+ /// Resets the Demo club to initial state: hard delete all data, then re-seed.
+ ///
+ public static async Task ResetDemoClubAsync(IServiceProvider services, Guid clubId)
+ {
+ using var scope = services.CreateScope();
+ var appDb = scope.ServiceProvider.GetRequiredService();
+ var triggerRepo = scope.ServiceProvider.GetRequiredService();
+
+ // Find creator ID for seeding
+ var club = await appDb.Clubs.FindAsync(clubId);
+ if (club == null) return;
+
+ var membership = await appDb.UserProfileClubs.FirstOrDefaultAsync(m => m.ClubId == clubId);
+ var creatorId = membership?.AssignedById ?? Guid.Empty;
+
+ // Hard delete all club data
+ await HardDeleteClubDataAsync(appDb, clubId);
+
+ // Re-seed demo data
+ await SeedDemoDataAsync(appDb, triggerRepo, clubId, creatorId);
+ }
+
+ private static async Task HardDeleteClubDataAsync(AppDbContext db, Guid clubId)
+ {
+ // Order matters due to FK constraints - delete children first
+ await db.Database.ExecuteSqlAsync($"DELETE FROM app.PersonExpenses WHERE ClubId = {clubId}");
+ await db.Database.ExecuteSqlAsync($"DELETE FROM app.GamePersons WHERE ClubId = {clubId}");
+ await db.Database.ExecuteSqlAsync($"DELETE FROM app.Games WHERE ClubId = {clubId}");
+ await db.Database.ExecuteSqlAsync($"DELETE FROM app.DayPersons WHERE ClubId = {clubId}");
+ await db.Database.ExecuteSqlAsync($"DELETE FROM app.Days WHERE ClubId = {clubId}");
+ await db.Database.ExecuteSqlAsync($"DELETE FROM app.ExpenseTriggers WHERE ClubId = {clubId}");
+ await db.Database.ExecuteSqlAsync($"DELETE FROM app.Expenses WHERE ClubId = {clubId}");
+ await db.Database.ExecuteSqlAsync($"DELETE FROM app.Persons WHERE ClubId = {clubId}");
+ }
+
+ private static async Task SeedDemoDataAsync(AppDbContext db, ITriggerRepository triggerRepo, Guid clubId, Guid creatorId)
+ {
+ var now = DateTime.UtcNow;
+
+ // 1) Seed Persons (8 Members + 2 Guests)
+ var persons = new List();
+ foreach (var name in MemberNames)
+ {
+ var person = new Person
+ {
+ Name = name,
+ PersonStatus = PersonStatus.Member,
+ ClubId = clubId,
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Persons.Add(person);
+ persons.Add(person);
+ }
+
+ foreach (var name in GuestNames)
+ {
+ var person = new Person
+ {
+ Name = name,
+ PersonStatus = PersonStatus.Guest,
+ ClubId = clubId,
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Persons.Add(person);
+ persons.Add(person);
+ }
+ await db.SaveChangesAsync();
+
+ // 2) Seed Expenses with Trigger mappings
+ var expenseData = new (string Name, decimal Price, ExpenseTriggerType TriggerType, bool IsInverse)[]
+ {
+ ("Gosse", 0.50m, ExpenseTriggerType.Gutter, false),
+ ("Pudel", 0.30m, ExpenseTriggerType.NoWood, false),
+ ("Klingel", 0.20m, ExpenseTriggerType.Bell, false),
+ ("Anwurffehler", 0.40m, ExpenseTriggerType.FirstThrowFail, false),
+ ("Kranz", 0.50m, ExpenseTriggerType.Circle, true),
+ ("Alle Neune", 1.00m, ExpenseTriggerType.Strike, true),
+ ("Gosse Anwurf", 1.00m, ExpenseTriggerType.FullGutter, false),
+ ("Ausgeschieden", 2.00m, ExpenseTriggerType.Eliminated, false),
+ ("Abwesenheit", 5.00m, ExpenseTriggerType.Absent, false),
+ ("Strafpunkt", 0.10m, ExpenseTriggerType.ExpensePoint, false)
+ };
+
+ var expenses = new List();
+ foreach (var (name, price, triggerType, isInverse) in expenseData)
+ {
+ var expense = new Expense
+ {
+ ClubId = clubId,
+ Name = name,
+ Description = $"Strafe fuer {name}",
+ Price = price,
+ IsOneClick = true,
+ IsInverse = isInverse,
+ IsVariable = false,
+ ExpenseType = ExpenseType.Monetary,
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Expenses.Add(expense);
+ expenses.Add(expense);
+ }
+ await db.SaveChangesAsync();
+
+ // 3) Create ExpenseTrigger mappings
+ for (int i = 0; i < expenses.Count; i++)
+ {
+ var expense = expenses[i];
+ var triggerType = expenseData[i].TriggerType;
+ var trigger = await triggerRepo.GetByTriggerTypeAsync(triggerType);
+
+ if (trigger != null)
+ {
+ var expenseTrigger = new ExpenseTrigger
+ {
+ ClubId = clubId,
+ ExpenseId = expense.Id,
+ TriggerId = trigger.Id,
+ AssignedAt = now,
+ AssignedById = creatorId
+ };
+ db.ExpenseTriggers.Add(expenseTrigger);
+ }
+ }
+ await db.SaveChangesAsync();
+
+ // 4) Seed Sample Days with Games
+ await SeedSampleDaysAsync(db, clubId, creatorId, persons, expenses, now);
+ }
+
+ private static async Task SeedSampleDaysAsync(
+ AppDbContext db,
+ Guid clubId,
+ Guid creatorId,
+ List persons,
+ List expenses,
+ DateTime now)
+ {
+ var members = persons.Where(p => p.PersonStatus == PersonStatus.Member).ToList();
+ var gosseExpense = expenses.First(e => e.Name == "Gosse");
+ var pudelExpense = expenses.First(e => e.Name == "Pudel");
+ var klingelExpense = expenses.First(e => e.Name == "Klingel");
+
+ // Day 1: 3 participants, 1 game, some expenses
+ var day1 = new Day
+ {
+ PostDate = now.AddDays(-14),
+ Status = DayStatus.Closed,
+ ClubId = clubId,
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Days.Add(day1);
+ await db.SaveChangesAsync();
+
+ var day1Participants = members.Take(3).ToList();
+ foreach (var p in day1Participants)
+ {
+ db.Set().Add(new DayPerson { DayId = day1.Id, ClubId = clubId, PersonId = p.Id });
+ }
+
+ var game1 = new Game
+ {
+ DayId = day1.Id,
+ ClubId = clubId,
+ GameType = "Training",
+ Status = GameStatus.Completed,
+ StartedAt = day1.PostDate,
+ CompletedAt = day1.PostDate.AddHours(2),
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Games.Add(game1);
+ await db.SaveChangesAsync();
+
+ foreach (var p in day1Participants)
+ {
+ db.Set().Add(new GamePerson { GameId = game1.Id, ClubId = clubId, PersonId = p.Id });
+ }
+ await db.SaveChangesAsync();
+
+ // Day 2: 5 participants, 2 games, diverse expenses
+ var day2 = new Day
+ {
+ PostDate = now.AddDays(-7),
+ Status = DayStatus.Closed,
+ ClubId = clubId,
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Days.Add(day2);
+ await db.SaveChangesAsync();
+
+ var day2Participants = members.Take(5).ToList();
+ foreach (var p in day2Participants)
+ {
+ db.Set().Add(new DayPerson { DayId = day2.Id, ClubId = clubId, PersonId = p.Id });
+ }
+
+ var game2a = new Game
+ {
+ DayId = day2.Id,
+ ClubId = clubId,
+ GameType = "Training",
+ Status = GameStatus.Completed,
+ StartedAt = day2.PostDate,
+ CompletedAt = day2.PostDate.AddHours(1),
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ var game2b = new Game
+ {
+ DayId = day2.Id,
+ ClubId = clubId,
+ GameType = "Totenkiste",
+ Status = GameStatus.Completed,
+ StartedAt = day2.PostDate.AddHours(1),
+ CompletedAt = day2.PostDate.AddHours(2),
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Games.Add(game2a);
+ db.Games.Add(game2b);
+ await db.SaveChangesAsync();
+
+ foreach (var p in day2Participants)
+ {
+ db.Set().Add(new GamePerson { GameId = game2a.Id, ClubId = clubId, PersonId = p.Id });
+ db.Set().Add(new GamePerson { GameId = game2b.Id, ClubId = clubId, PersonId = p.Id });
+ }
+ await db.SaveChangesAsync();
+
+ // Day 3: 4 participants, 1 game (recent, still open)
+ var day3 = new Day
+ {
+ PostDate = now.AddDays(-1),
+ Status = DayStatus.Started,
+ ClubId = clubId,
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Days.Add(day3);
+ await db.SaveChangesAsync();
+
+ var day3Participants = members.Take(4).ToList();
+ foreach (var p in day3Participants)
+ {
+ db.Set().Add(new DayPerson { DayId = day3.Id, ClubId = clubId, PersonId = p.Id });
+ }
+
+ var game3 = new Game
+ {
+ DayId = day3.Id,
+ ClubId = clubId,
+ GameType = "Training",
+ Status = GameStatus.Active,
+ StartedAt = day3.PostDate,
+ CreatedAt = now,
+ CreatedById = creatorId
+ };
+ db.Games.Add(game3);
+ await db.SaveChangesAsync();
+
+ foreach (var p in day3Participants)
+ {
+ db.Set().Add(new GamePerson { GameId = game3.Id, ClubId = clubId, PersonId = p.Id });
+ }
+ await db.SaveChangesAsync();
+ }
+}
diff --git a/src/Koogle.Infrastructure/DependencyInjection.cs b/src/Koogle.Infrastructure/DependencyInjection.cs
index 38419cb..9178384 100644
--- a/src/Koogle.Infrastructure/DependencyInjection.cs
+++ b/src/Koogle.Infrastructure/DependencyInjection.cs
@@ -4,6 +4,7 @@ using Koogle.Infrastructure.Data;
using Koogle.Infrastructure.Identity;
using Koogle.Infrastructure.Repositories;
using Koogle.Infrastructure.Security;
+using Koogle.Infrastructure.Services;
using KoogleApp.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -84,6 +85,7 @@ public static class DependencyInjection
// Services
//services.AddScoped();
+ services.AddScoped();
services.AddCascadingAuthenticationState();
diff --git a/src/Koogle.Infrastructure/Services/DemoResetService.cs b/src/Koogle.Infrastructure/Services/DemoResetService.cs
new file mode 100644
index 0000000..bde69cf
--- /dev/null
+++ b/src/Koogle.Infrastructure/Services/DemoResetService.cs
@@ -0,0 +1,78 @@
+using Koogle.Domain.Interfaces;
+using Koogle.Infrastructure.Data;
+using KoogleApp.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+
+namespace Koogle.Infrastructure.Services;
+
+///
+/// Service for resetting the Demo club to initial state.
+///
+public class DemoResetService : IDemoResetService
+{
+ private readonly IServiceProvider _services;
+ private readonly IConfiguration _config;
+ private readonly AppDbContext _db;
+ private Guid? _demoClubId;
+
+ public DemoResetService(
+ IServiceProvider services,
+ IConfiguration config,
+ AppDbContext db)
+ {
+ _services = services;
+ _config = config;
+ _db = db;
+ }
+
+ ///
+ public async Task ResetDemoClubAsync(CancellationToken ct = default)
+ {
+ var clubId = await GetDemoClubIdAsync(ct);
+ if (!clubId.HasValue) return false;
+
+ await DemoSeeder.ResetDemoClubAsync(_services, clubId.Value);
+ return true;
+ }
+
+ ///
+ public bool IsDemoClub(Guid clubId)
+ {
+ var demoClubId = GetDemoClubId();
+ return demoClubId.HasValue && demoClubId.Value == clubId;
+ }
+
+ ///
+ public Guid? GetDemoClubId()
+ {
+ if (_demoClubId.HasValue) return _demoClubId;
+
+ var demoEnabled = _config.GetValue("Bootstrap:Demo:Enabled");
+ if (!demoEnabled) return null;
+
+ var demoClubName = _config["Bootstrap:Demo:ClubName"] ?? "Demo";
+ var club = _db.Clubs
+ .AsNoTracking()
+ .FirstOrDefault(c => c.Name == demoClubName && !c.IsDeleted);
+
+ _demoClubId = club?.Id;
+ return _demoClubId;
+ }
+
+ private async Task GetDemoClubIdAsync(CancellationToken ct)
+ {
+ if (_demoClubId.HasValue) return _demoClubId;
+
+ var demoEnabled = _config.GetValue("Bootstrap:Demo:Enabled");
+ if (!demoEnabled) return null;
+
+ var demoClubName = _config["Bootstrap:Demo:ClubName"] ?? "Demo";
+ var club = await _db.Clubs
+ .AsNoTracking()
+ .FirstOrDefaultAsync(c => c.Name == demoClubName && !c.IsDeleted, ct);
+
+ _demoClubId = club?.Id;
+ return _demoClubId;
+ }
+}
diff --git a/src/Koogle.Web/Components/Pages/Account/Login.razor b/src/Koogle.Web/Components/Pages/Account/Login.razor
index e8f9b12..b942917 100644
--- a/src/Koogle.Web/Components/Pages/Account/Login.razor
+++ b/src/Koogle.Web/Components/Pages/Account/Login.razor
@@ -6,6 +6,7 @@
@inject NavigationManager NavigationManager
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@inject IHttpContextAccessor HttpContextAccessor
+@inject IConfiguration Configuration