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:
Sisyphus CI
2026-03-03 14:23:50 +01:00
parent c44cb1c801
commit b7854e9571
4 changed files with 797 additions and 3 deletions

View 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

View File

@@ -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<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)