feat(seed): add development seed data script
- Create SeedDataService in Infrastructure/Seed with idempotent seeding - Seed 2 clubs: Sunrise Tennis Club, Valley Cycling Club - Seed 7 member records (5 unique Keycloak test users) - Seed 8 work items covering all status states - Seed 5 shifts with date variety (past, today, future) - Seed shift signups for realistic partial capacity - Register SeedDataService in Program.cs with development-only guard - Use deterministic GUID generation from club names - Ensure all tenant IDs match for RLS compliance - Track in learnings.md and evidence files for Task 22 QA
This commit is contained in:
132
.sisyphus/evidence/task-11-implementation.txt
Normal file
132
.sisyphus/evidence/task-11-implementation.txt
Normal file
@@ -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<SeedDataService>();`
|
||||||
|
- **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<SeedDataService>();
|
||||||
|
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
|
||||||
@@ -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`
|
- Dev server output: `.sisyphus/evidence/task-5-dev-server.txt`
|
||||||
- Git commit: `chore(frontend): initialize Next.js project with Tailwind and shadcn/ui`
|
- 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<AppDbContext>();
|
||||||
|
|
||||||
|
// 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<SeedDataService>();
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
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.AddOpenApi();
|
||||||
|
|
||||||
|
builder.Services.AddMultiTenant<TenantInfo>()
|
||||||
|
.WithHeaderStrategy("X-Tenant-Id")
|
||||||
|
.WithClaimStrategy("tenant_id")
|
||||||
|
.WithInMemoryStore(options =>
|
||||||
|
{
|
||||||
|
options.IsCaseSensitive = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddScoped<ITenantProvider, TenantProvider>();
|
||||||
|
builder.Services.AddScoped<SeedDataService>();
|
||||||
|
|
||||||
|
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<IClaimsTransformation, ClubRoleClaimsTransformation>();
|
||||||
|
|
||||||
|
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<AppDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
|
var seedService = scope.ServiceProvider.GetRequiredService<SeedDataService>();
|
||||||
|
await seedService.SeedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseMultiTenant();
|
||||||
|
app.UseMiddleware<TenantValidationMiddleware>();
|
||||||
|
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[]
|
var summaries = new[]
|
||||||
{
|
{
|
||||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||||
@@ -33,9 +99,14 @@ app.MapGet("/weatherforecast", () =>
|
|||||||
})
|
})
|
||||||
.WithName("GetWeatherForecast");
|
.WithName("GetWeatherForecast");
|
||||||
|
|
||||||
|
app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||||
{
|
{
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public partial class Program { }
|
||||||
|
|||||||
448
backend/WorkClub.Infrastructure/Seed/SeedDataService.cs
Normal file
448
backend/WorkClub.Infrastructure/Seed/SeedDataService.cs
Normal file
@@ -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<AppDbContext>();
|
||||||
|
|
||||||
|
// Seed clubs
|
||||||
|
if (!context.Clubs.Any())
|
||||||
|
{
|
||||||
|
var clubs = new List<Club>
|
||||||
|
{
|
||||||
|
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<Member>
|
||||||
|
{
|
||||||
|
// 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<WorkItem>
|
||||||
|
{
|
||||||
|
// 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<Shift>
|
||||||
|
{
|
||||||
|
// 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<ShiftSignup>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user