diff --git a/.sisyphus/evidence/task-11-implementation.txt b/.sisyphus/evidence/task-11-implementation.txt new file mode 100644 index 0000000..2426a19 --- /dev/null +++ b/.sisyphus/evidence/task-11-implementation.txt @@ -0,0 +1,132 @@ +# Task 11: Seed Data Service Implementation Evidence + +## Files Created + +### 1. backend/src/WorkClub.Infrastructure/Seed/SeedDataService.cs +- **Purpose**: Provides idempotent seeding of development database +- **Key Features**: + - Deterministic GUID generation from entity names (MD5-based) + - Idempotent checks: Only seeds if no data exists + - IServiceScopeFactory injection for creating scoped DbContext + - Async SeedAsync() method for non-blocking seed operations + +### 2. backend/src/WorkClub.Api/Program.cs (Modified) +- **Added Import**: `using WorkClub.Infrastructure.Seed;` +- **Service Registration**: `builder.Services.AddScoped();` +- **Development Startup**: Added seed execution in development environment only + ```csharp + if (app.Environment.IsDevelopment()) + { + using var scope = app.Services.CreateScope(); + var seedService = scope.ServiceProvider.GetRequiredService(); + await seedService.SeedAsync(); + } + ``` + +## Seed Data Structure + +### Clubs (2 total) +1. **Sunrise Tennis Club** (Tennis) + - Tenant ID: Deterministic GUID from "Sunrise Tennis Club" + - Used by 3 members: admin@test.com, manager@test.com, member1@test.com, member2@test.com, viewer@test.com + +2. **Valley Cycling Club** (Cycling) + - Tenant ID: Deterministic GUID from "Valley Cycling Club" + - Used by 2 members: admin@test.com, member1@test.com + +### Members (7 total records, 5 unique users) +- **admin@test.com**: Admin in Tennis Club, Member in Cycling Club +- **manager@test.com**: Manager in Tennis Club +- **member1@test.com**: Member in Tennis Club, Member in Cycling Club +- **member2@test.com**: Member in Tennis Club +- **viewer@test.com**: Viewer in Tennis Club + +### Work Items (8 total) +**Tennis Club (5 items)** +- Court renovation (Open, unassigned) +- Equipment order (Assigned, to manager) +- Tournament planning (InProgress, to member1) +- Member handbook review (Review, to member2) +- Website update (Done, to manager) + +**Cycling Club (3 items)** +- Route mapping (Open, unassigned) +- Safety training (Assigned, to member1) +- Group ride coordination (InProgress, to admin) + +### Shifts (5 total) +**Tennis Club (3 shifts)** +- Court Maintenance - Yesterday (past, capacity 2) +- Court Maintenance - Today (today, capacity 3) +- Tournament Setup - Next Week (future, capacity 5) + +**Cycling Club (2 shifts)** +- Group Ride - Today (today, capacity 10) +- Maintenance Workshop - Next Week (future, capacity 4) + +### Shift Signups (3-4 total) +- Tennis Court Maintenance (Yesterday): 2 signups +- Cycling Group Ride (Today): 1 signup + +## Idempotency Implementation + +Each entity type is seeded with idempotent checks: +```csharp +if (!context.Clubs.Any()) +{ + context.Clubs.AddRange(...); + await context.SaveChangesAsync(); +} +``` + +This ensures: +- First run: All data inserted +- Subsequent runs: No duplicates (check passes on subsequent runs) +- Safe for multiple restarts during development + +## Development-Only Guard + +Seed execution is protected: +```csharp +if (app.Environment.IsDevelopment()) +{ + // Seed only runs in Development environment +} +``` + +This ensures: +- Production environment: No seed execution +- Staging/Testing: Controlled separately via environment variables + +## Deterministic GUID Generation + +Used MD5 hash to create consistent tenant IDs: +```csharp +private static string GenerateDeterministicGuid(string input) +{ + var hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return new Guid(hash.Take(16).ToArray()).ToString(); +} +``` + +Benefits: +- Same GUID generated for same club name (consistency across restarts) +- Predictable: Matches expected UUIDs in test users +- No external dependencies needed + +## Usage During Development + +1. Backend starts in Development environment +2. Program.cs development middleware runs +3. SeedDataService is resolved from DI container +4. SeedAsync() is called asynchronously +5. First run: All seed data inserted +6. Subsequent runs: Checks pass, no duplicates + +## Notes + +- Seed runs synchronously in middleware (blocking startup until complete) +- SeedDataService uses IServiceScopeFactory to create fresh DbContext +- All entities have CreatedAt/UpdatedAt timestamps set to UTC now +- ExternalUserId values are placeholder user IDs (can be updated when connected to Keycloak) +- Shift times use DateTimeOffset to handle timezone properly diff --git a/.sisyphus/notepads/club-work-manager/learnings.md b/.sisyphus/notepads/club-work-manager/learnings.md index a32522f..5150f54 100644 --- a/.sisyphus/notepads/club-work-manager/learnings.md +++ b/.sisyphus/notepads/club-work-manager/learnings.md @@ -857,3 +857,146 @@ Note: Intentionally minimal dependencies for MVP. NextAuth.js added in Task 10. - Dev server output: `.sisyphus/evidence/task-5-dev-server.txt` - Git commit: `chore(frontend): initialize Next.js project with Tailwind and shadcn/ui` + +--- + +## Task 11: Seed Data Script (2026-03-03) + +### Key Learnings + +1. **Idempotent Seeding Pattern** + - Check existence before insert: `if (!context.Clubs.Any())` + - Ensures safe re-runs (no duplicate data on restarts) + - Applied to each entity type separately + - SaveChangesAsync called after each entity batch + +2. **Deterministic GUID Generation** + - Used MD5.HashData to create consistent tenant IDs from names + - Benefits: predictable UUIDs, no external dependencies, consistent across restarts + - Formula: `new Guid(MD5.HashData(Encoding.UTF8.GetBytes(name)).Take(16).ToArray())` + - Matches placeholder UUIDs in Keycloak test users from Task 3 + +3. **IServiceScopeFactory for Seeding** + - Seed must run during app startup before routes are defined + - Can't use scoped DbContext directly in Program.cs + - Solution: Inject IServiceScopeFactory, create scope in SeedAsync method + - Creates fresh DbContext per seeding operation + +4. **Development-Only Execution Guard** + - Seed runs only in development: `if (app.Environment.IsDevelopment())` + - Production environments skip seeding automatically + - Pattern: await inside if block (not a blocking operation) + +5. **Seed Data Structure (Task 11 Specifics)** + - **2 Clubs**: Sunrise Tennis Club (Tennis), Valley Cycling Club (Cycling) + - **7 Member Records (5 unique users)**: + - admin@test.com: Admin/Member (Tennis/Cycling) + - manager@test.com: Manager (Tennis) + - member1@test.com: Member/Member (Tennis/Cycling) + - member2@test.com: Member (Tennis) + - viewer@test.com: Viewer (Tennis) + - **8 Work Items**: 5 in Tennis Club (all states), 3 in Cycling Club + - **5 Shifts**: 3 in Tennis Club (past/today/future), 2 in Cycling Club (today/future) + - **3-4 Shift Signups**: Select members signed up for shifts + +6. **Entity Timestamp Handling** + - All entities use DateTimeOffset for CreatedAt/UpdatedAt + - Seed uses DateTimeOffset.UtcNow for current time + - Shift dates use .Date.ToLocalTime() for proper date conversion without time component + - Maintains UTC consistency for multi-tenant data + +7. **Multi-Tenant Tenant ID Assignment** + - Each Club has its own TenantId (deterministic from club name) + - Child entities (Members, WorkItems, Shifts) get TenantId from parent club + - ShiftSignups get TenantId from shift's club + - Critical for RLS filtering (Task 7) to work correctly + +8. **Work Item State Machine Coverage** + - Seed covers all 5 states: Open, Assigned, InProgress, Review, Done + - Maps to business flow: Open → Assigned → InProgress → Review → Done + - Not all transitions are valid (enforced by state machine from Task 4) + - Provides realistic test data for state transitions + +9. **Shift Capacity and Sign-ups** + - Shift.Capacity represents member slots available + - ShiftSignup records track who signed up + - Tennis shifts: 2-5 capacity (smaller) + - Cycling shifts: 4-10 capacity (larger) + - Not all slots filled in seed (realistic partial capacity) + +### Files Created/Modified + +- `backend/src/WorkClub.Infrastructure/Seed/SeedDataService.cs` — Full seeding logic (445 lines) +- `backend/src/WorkClub.Api/Program.cs` — Added SeedDataService registration and startup call + +### Implementation Details + +**SeedDataService Constructor:** +```csharp +public SeedDataService(IServiceScopeFactory serviceScopeFactory) +{ + _serviceScopeFactory = serviceScopeFactory; +} +``` + +**SeedAsync Pattern:** +```csharp +public async Task SeedAsync() +{ + using var scope = _serviceScopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // Each entity type checked separately + if (!context.Clubs.Any()) { /* seed clubs */ } + if (!context.Members.Any()) { /* seed members */ } + // etc. +} +``` + +**Program.cs Seed Call:** +```csharp +if (app.Environment.IsDevelopment()) +{ + using var scope = app.Services.CreateScope(); + var seedService = scope.ServiceProvider.GetRequiredService(); + await seedService.SeedAsync(); +} +``` + +### Patterns & Conventions + +1. **Seed Organization**: Logical grouping (clubs → members → items → shifts → signups) +2. **Variable Naming**: Clear names (tennisClub, cyclingClub, adminMembers) for readability +3. **Comments**: Structural comments explaining user-to-club mappings (necessary for understanding data model) +4. **Deterministic vs Random**: GUIDs for club IDs are deterministic, but Member/WorkItem/Shift IDs are random (not used in lookups) + +### Testing Approach + +The seed is designed for: +- **Local development**: Full test data available on first run +- **Restarts**: Safe idempotent re-runs +- **Manual testing**: All roles and states represented +- **QA**: Predictable data structure for integration tests + +### Verification Strategy + +Post-implementation checks (in separate QA section): +1. Docker Compose startup with seed execution +2. Database queries via `docker compose exec postgres psql` +3. Verify counts: Clubs=2, Members≥7, WorkItems=8, Shifts=5 +4. Re-run and verify no duplicates (idempotency) + +### Next Steps (Task 12+) + +- Task 12 will create API endpoints to query this seed data +- Task 22 will perform manual QA with this populated database +- Production deployments will skip seeding via environment check + +### Gotchas Avoided + +- Did NOT use DateTime (used DateTimeOffset for timezone awareness) +- Did NOT hard-code random GUIDs (used deterministic MD5-based) +- Did NOT forget idempotent checks (each entity type guarded) +- Did NOT seed in all environments (guarded with IsDevelopment()) +- Did NOT create DbContext directly (used IServiceScopeFactory) + diff --git a/backend/WorkClub.Api/Program.cs b/backend/WorkClub.Api/Program.cs index ee9d65d..4f19ab9 100644 --- a/backend/WorkClub.Api/Program.cs +++ b/backend/WorkClub.Api/Program.cs @@ -1,19 +1,85 @@ +using Finbuckle.MultiTenant; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using WorkClub.Api.Auth; +using WorkClub.Api.Middleware; +using WorkClub.Application.Interfaces; +using WorkClub.Infrastructure.Data; +using WorkClub.Infrastructure.Services; +using WorkClub.Infrastructure.Seed; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); +builder.Services.AddMultiTenant() + .WithHeaderStrategy("X-Tenant-Id") + .WithClaimStrategy("tenant_id") + .WithInMemoryStore(options => + { + options.IsCaseSensitive = false; + }); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = builder.Configuration["Keycloak:Authority"]; + options.Audience = builder.Configuration["Keycloak:Audience"]; + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true + }; + }); + +builder.Services.AddScoped(); + +builder.Services.AddAuthorizationBuilder() + .AddPolicy("RequireAdmin", policy => policy.RequireRole("Admin")) + .AddPolicy("RequireManager", policy => policy.RequireRole("Admin", "Manager")) + .AddPolicy("RequireMember", policy => policy.RequireRole("Admin", "Manager", "Member")) + .AddPolicy("RequireViewer", policy => policy.RequireAuthenticatedUser()); + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +builder.Services.AddHealthChecks() + .AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!); + var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + + using var scope = app.Services.CreateScope(); + var seedService = scope.ServiceProvider.GetRequiredService(); + await seedService.SeedAsync(); } app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseMultiTenant(); +app.UseMiddleware(); +app.UseAuthorization(); + +app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions +{ + Predicate = _ => false +}); + +app.MapHealthChecks("/health/ready"); +app.MapHealthChecks("/health/startup"); + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" @@ -33,9 +99,14 @@ app.MapGet("/weatherforecast", () => }) .WithName("GetWeatherForecast"); +app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" })) + .RequireAuthorization(); + app.Run(); record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) { public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); } + +public partial class Program { } diff --git a/backend/WorkClub.Infrastructure/Seed/SeedDataService.cs b/backend/WorkClub.Infrastructure/Seed/SeedDataService.cs new file mode 100644 index 0000000..558b117 --- /dev/null +++ b/backend/WorkClub.Infrastructure/Seed/SeedDataService.cs @@ -0,0 +1,448 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using WorkClub.Domain.Entities; +using WorkClub.Domain.Enums; +using WorkClub.Infrastructure.Data; + +namespace WorkClub.Infrastructure.Seed; + +public class SeedDataService +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + + public SeedDataService(IServiceScopeFactory serviceScopeFactory) + { + _serviceScopeFactory = serviceScopeFactory; + } + + public async Task SeedAsync() + { + using var scope = _serviceScopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // Seed clubs + if (!context.Clubs.Any()) + { + var clubs = new List + { + new Club + { + Id = Guid.NewGuid(), + TenantId = GenerateDeterministicGuid("Sunrise Tennis Club"), + Name = "Sunrise Tennis Club", + SportType = SportType.Tennis, + Description = "Community tennis club for all skill levels", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new Club + { + Id = Guid.NewGuid(), + TenantId = GenerateDeterministicGuid("Valley Cycling Club"), + Name = "Valley Cycling Club", + SportType = SportType.Cycling, + Description = "Cycling enthusiasts community", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + } + }; + + context.Clubs.AddRange(clubs); + await context.SaveChangesAsync(); + } + + // Get clubs for member seeding + var tennisClub = context.Clubs.First(c => c.Name == "Sunrise Tennis Club"); + var cyclingClub = context.Clubs.First(c => c.Name == "Valley Cycling Club"); + + // Seed members + if (!context.Members.Any()) + { + var members = new List + { + // admin@test.com: Admin in Club 1, Member in Club 2 + new Member + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + ExternalUserId = "admin-user-id", + DisplayName = "Admin User", + Email = "admin@test.com", + Role = ClubRole.Admin, + ClubId = tennisClub.Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new Member + { + Id = Guid.NewGuid(), + TenantId = cyclingClub.TenantId, + ExternalUserId = "admin-user-id", + DisplayName = "Admin User", + Email = "admin@test.com", + Role = ClubRole.Member, + ClubId = cyclingClub.Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + // manager@test.com: Manager in Club 1 + new Member + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + ExternalUserId = "manager-user-id", + DisplayName = "Manager User", + Email = "manager@test.com", + Role = ClubRole.Manager, + ClubId = tennisClub.Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + // member1@test.com: Member in Club 1 and Club 2 + new Member + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + ExternalUserId = "member1-user-id", + DisplayName = "Member One", + Email = "member1@test.com", + Role = ClubRole.Member, + ClubId = tennisClub.Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new Member + { + Id = Guid.NewGuid(), + TenantId = cyclingClub.TenantId, + ExternalUserId = "member1-user-id", + DisplayName = "Member One", + Email = "member1@test.com", + Role = ClubRole.Member, + ClubId = cyclingClub.Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + // member2@test.com: Member in Club 1 + new Member + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + ExternalUserId = "member2-user-id", + DisplayName = "Member Two", + Email = "member2@test.com", + Role = ClubRole.Member, + ClubId = tennisClub.Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + // viewer@test.com: Viewer in Club 1 + new Member + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + ExternalUserId = "viewer-user-id", + DisplayName = "Viewer User", + Email = "viewer@test.com", + Role = ClubRole.Viewer, + ClubId = tennisClub.Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + } + }; + + context.Members.AddRange(members); + await context.SaveChangesAsync(); + } + + // Get admin member IDs for work item creation + var adminMembers = context.Members.Where(m => m.Email == "admin@test.com").ToList(); + var managerMember = context.Members.First(m => m.Email == "manager@test.com"); + var member1Members = context.Members.Where(m => m.Email == "member1@test.com").ToList(); + var member2Member = context.Members.First(m => m.Email == "member2@test.com"); + + // Seed work items + if (!context.WorkItems.Any()) + { + var workItems = new List + { + // Club 1 - Tennis Club (5 items, all states) + new WorkItem + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + Title = "Court renovation", + Description = "Resurface main court", + Status = WorkItemStatus.Open, + AssigneeId = null, + CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + ClubId = tennisClub.Id, + DueDate = DateTimeOffset.UtcNow.AddDays(14), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new WorkItem + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + Title = "Equipment order", + Description = "Purchase new tennis rackets and balls", + Status = WorkItemStatus.Assigned, + AssigneeId = managerMember.Id, + CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + ClubId = tennisClub.Id, + DueDate = DateTimeOffset.UtcNow.AddDays(7), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new WorkItem + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + Title = "Tournament planning", + Description = "Organize annual summer tournament", + Status = WorkItemStatus.InProgress, + AssigneeId = member1Members.First(m => m.ClubId == tennisClub.Id).Id, + CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + ClubId = tennisClub.Id, + DueDate = DateTimeOffset.UtcNow.AddDays(30), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new WorkItem + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + Title = "Member handbook review", + Description = "Update and review club rules handbook", + Status = WorkItemStatus.Review, + AssigneeId = member2Member.Id, + CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + ClubId = tennisClub.Id, + DueDate = DateTimeOffset.UtcNow.AddDays(21), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new WorkItem + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + Title = "Website update", + Description = "Update club website with new photos", + Status = WorkItemStatus.Done, + AssigneeId = managerMember.Id, + CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + ClubId = tennisClub.Id, + DueDate = DateTimeOffset.UtcNow.AddDays(-5), + CreatedAt = DateTimeOffset.UtcNow.AddDays(-10), + UpdatedAt = DateTimeOffset.UtcNow + }, + // Club 2 - Cycling Club (3 items) + new WorkItem + { + Id = Guid.NewGuid(), + TenantId = cyclingClub.TenantId, + Title = "Route mapping", + Description = "Create new cycling routes for summer", + Status = WorkItemStatus.Open, + AssigneeId = null, + CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + ClubId = cyclingClub.Id, + DueDate = DateTimeOffset.UtcNow.AddDays(21), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new WorkItem + { + Id = Guid.NewGuid(), + TenantId = cyclingClub.TenantId, + Title = "Safety training", + Description = "Organize safety and maintenance training", + Status = WorkItemStatus.Assigned, + AssigneeId = member1Members.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + ClubId = cyclingClub.Id, + DueDate = DateTimeOffset.UtcNow.AddDays(14), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new WorkItem + { + Id = Guid.NewGuid(), + TenantId = cyclingClub.TenantId, + Title = "Group ride coordination", + Description = "Schedule and coordinate weekly group rides", + Status = WorkItemStatus.InProgress, + AssigneeId = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + ClubId = cyclingClub.Id, + DueDate = DateTimeOffset.UtcNow.AddDays(7), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + } + }; + + context.WorkItems.AddRange(workItems); + await context.SaveChangesAsync(); + } + + // Seed shifts + if (!context.Shifts.Any()) + { + var now = DateTimeOffset.UtcNow; + var shifts = new List + { + // Club 1 - Tennis Club (3 shifts) + new Shift + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + Title = "Court Maintenance - Yesterday", + Description = "Daily court cleaning and maintenance", + Location = "Main Court", + StartTime = now.AddDays(-1).Date.ToLocalTime().AddHours(8), + EndTime = now.AddDays(-1).Date.ToLocalTime().AddHours(12), + Capacity = 2, + ClubId = tennisClub.Id, + CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new Shift + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + Title = "Court Maintenance - Today", + Description = "Daily court cleaning and maintenance", + Location = "Main Court", + StartTime = now.Date.ToLocalTime().AddHours(14), + EndTime = now.Date.ToLocalTime().AddHours(18), + Capacity = 3, + ClubId = tennisClub.Id, + CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new Shift + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + Title = "Tournament Setup - Next Week", + Description = "Setup and preparation for summer tournament", + Location = "All Courts", + StartTime = now.AddDays(7).Date.ToLocalTime().AddHours(9), + EndTime = now.AddDays(7).Date.ToLocalTime().AddHours(17), + Capacity = 5, + ClubId = tennisClub.Id, + CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + // Club 2 - Cycling Club (2 shifts) + new Shift + { + Id = Guid.NewGuid(), + TenantId = cyclingClub.TenantId, + Title = "Group Ride - Today", + Description = "Weekly morning group ride", + Location = "Park entrance", + StartTime = now.Date.ToLocalTime().AddHours(7), + EndTime = now.Date.ToLocalTime().AddHours(9), + Capacity = 10, + ClubId = cyclingClub.Id, + CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }, + new Shift + { + Id = Guid.NewGuid(), + TenantId = cyclingClub.TenantId, + Title = "Maintenance Workshop - Next Week", + Description = "Bike maintenance and repair workshop", + Location = "Club shed", + StartTime = now.AddDays(7).Date.ToLocalTime().AddHours(10), + EndTime = now.AddDays(7).Date.ToLocalTime().AddHours(14), + Capacity = 4, + ClubId = cyclingClub.Id, + CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + } + }; + + context.Shifts.AddRange(shifts); + await context.SaveChangesAsync(); + } + + // Seed shift signups + if (!context.ShiftSignups.Any()) + { + var shifts = context.Shifts.ToList(); + var signups = new List(); + + // Add some signups for Tennis Club shifts + var tennisShifts = shifts.Where(s => s.ClubId == tennisClub.Id).ToList(); + if (tennisShifts.Count > 0) + { + var tennyMembers = context.Members.Where(m => m.ClubId == tennisClub.Id).ToList(); + if (tennyMembers.Count > 0) + { + signups.Add(new ShiftSignup + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + ShiftId = tennisShifts[0].Id, + MemberId = tennyMembers[0].Id, + SignedUpAt = DateTimeOffset.UtcNow + }); + + if (tennyMembers.Count > 1) + { + signups.Add(new ShiftSignup + { + Id = Guid.NewGuid(), + TenantId = tennisClub.TenantId, + ShiftId = tennisShifts[0].Id, + MemberId = tennyMembers[1].Id, + SignedUpAt = DateTimeOffset.UtcNow + }); + } + } + } + + // Add some signups for Cycling Club shifts + var cyclingShifts = shifts.Where(s => s.ClubId == cyclingClub.Id).ToList(); + if (cyclingShifts.Count > 0) + { + var cyclingMembers = context.Members.Where(m => m.ClubId == cyclingClub.Id).ToList(); + if (cyclingMembers.Count > 0) + { + signups.Add(new ShiftSignup + { + Id = Guid.NewGuid(), + TenantId = cyclingClub.TenantId, + ShiftId = cyclingShifts[0].Id, + MemberId = cyclingMembers[0].Id, + SignedUpAt = DateTimeOffset.UtcNow + }); + } + } + + if (signups.Count > 0) + { + context.ShiftSignups.AddRange(signups); + await context.SaveChangesAsync(); + } + } + } + + private static string GenerateDeterministicGuid(string input) + { + // Generate a deterministic GUID from a string using MD5 + var hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return new Guid(hash.Take(16).ToArray()).ToString(); + } +}