Tasks 21-24 were completed and committed but not marked in plan: - Task 21:c29cff3(Login + Club Picker + Dashboard) - Task 22:7a2b79a(Docker Compose Full Stack) - Task 23:6124557(Backend Dockerfiles) - Task 24:6124557(Frontend Dockerfiles) Updated plan to reflect actual completion state.
2612 lines
117 KiB
Markdown
2612 lines
117 KiB
Markdown
# Multi-Tenant Club Work Manager
|
||
|
||
## TL;DR
|
||
|
||
> **Quick Summary**: Build a greenfield multi-tenant SaaS application for clubs (tennis, cycling, etc.) to manage work items (tasks + time-slot shifts) across their members. Credential-based multi-tenancy with PostgreSQL RLS, Keycloak auth, .NET 10 backend, Next.js frontend, deployed to Kubernetes.
|
||
>
|
||
> **Deliverables**:
|
||
> - .NET 10 REST API with multi-tenant data isolation (RLS + Finbuckle)
|
||
> - Next.js frontend with Tailwind + shadcn/ui, club-switcher, task & shift management
|
||
> - Keycloak integration for authentication with multi-club membership
|
||
> - PostgreSQL schema with RLS policies and EF Core migrations
|
||
> - Docker Compose for local development (hot reload, Keycloak, PostgreSQL)
|
||
> - Kubernetes manifests (Kustomize base + dev overlay)
|
||
> - Comprehensive TDD test suite (xUnit + Testcontainers, Vitest + RTL, Playwright E2E)
|
||
> - Seed data for development (2 clubs, 5 users, sample tasks + shifts)
|
||
>
|
||
> **Estimated Effort**: Large
|
||
> **Parallel Execution**: YES — 7 waves
|
||
> **Critical Path**: Monorepo scaffold → Auth pipeline → Multi-tenancy layer → Domain model → API endpoints → Frontend → K8s manifests
|
||
|
||
---
|
||
|
||
## Context
|
||
|
||
### Original Request
|
||
Build a multi-tenant internet application for managing work items over several members of a club (e.g. Tennis club, cycling club). Backend in .NET + PostgreSQL, frontend in Next.js + Bun, deployed to Kubernetes with local Docker Compose for development.
|
||
|
||
### Interview Summary
|
||
**Key Discussions**:
|
||
- **Tenant identification**: Credential-based (not subdomain). User logs in, JWT claims identify club memberships. Active club selected via X-Tenant-Id header.
|
||
- **Work items**: Hybrid — task-based (5-state: Open → Assigned → In Progress → Review → Done) AND time-slot shift scheduling with member sign-up.
|
||
- **Auth**: Keycloak (self-hosted), single realm, user attributes for club membership, custom protocol mapper for JWT claims.
|
||
- **Multi-club**: Users can belong to multiple clubs with different roles (Admin/Manager/Member/Viewer per club).
|
||
- **Scale**: MVP — 1-5 clubs, <100 users.
|
||
- **Testing**: TDD approach (tests first).
|
||
- **Notifications**: None for MVP.
|
||
|
||
**Research Findings**:
|
||
- **Finbuckle.MultiTenant**: ClaimStrategy + HeaderStrategy fallback is production-proven (fullstackhero/dotnet-starter-kit pattern).
|
||
- **Middleware order**: `UseAuthentication()` → `UseMultiTenant()` → `UseAuthorization()` (single Keycloak realm, ClaimStrategy needs claims).
|
||
- **RLS safety**: Use `SET LOCAL` (transaction-scoped) not `SET` to prevent stale tenant context in pooled connections.
|
||
- **EF Core + RLS**: Do NOT use Finbuckle's `IsMultiTenant()` shadow property — use explicit `TenantId` column + RLS only (avoids double-filtering).
|
||
- **Bun**: P99 latency issues with Next.js SSR (340ms vs 120ms Node.js). Use Bun for dev/package management, Node.js for production.
|
||
- **.NET 10**: Built-in `AddOpenApi()` (no Swashbuckle). 49% faster than .NET 8.
|
||
- **Keycloak**: Use `--import-realm` for dev bootstrapping. User attributes + custom protocol mapper for club claims.
|
||
- **Kustomize**: Simpler than Helm for MVP. Base + overlays structure.
|
||
|
||
### Metis Review
|
||
**Identified Gaps** (all resolved):
|
||
- **Club data model**: Club is both tenant container AND domain entity (name, sport type). Provisioned via Keycloak admin + seed script.
|
||
- **User-Club DB relationship**: `UserClubMembership` junction table needed for assignment queries. Synced from Keycloak on first login.
|
||
- **Task workflow rules**: Admin/Manager assigns. Assignee transitions through states. Only backward: Review → In Progress. Concurrency via EF Core `RowVersion`.
|
||
- **Shift semantics**: First-come-first-served sign-up. Cancel anytime before start. No waitlist. Optimistic concurrency for last-slot race.
|
||
- **Seed data**: 2 clubs, 5 users (1 admin, 1 manager, 2 members, 1 viewer), sample tasks + shifts.
|
||
- **API style**: REST + OpenAPI (built-in). URL: `/api/tasks` with tenant from header, not path.
|
||
- **Data fetching**: TanStack Query (client) + Server Components (initial load).
|
||
- **First login UX**: 1 club → auto-select. Multiple → picker. Zero → "Contact admin".
|
||
- **RLS migration safety**: `bypass_rls_policy` on all RLS-enabled tables for migrations.
|
||
|
||
---
|
||
|
||
## Work Objectives
|
||
|
||
### Core Objective
|
||
Deliver a working multi-tenant club work management application where authenticated members can manage tasks and sign up for shifts within their club context, with full data isolation between tenants.
|
||
|
||
### Concrete Deliverables
|
||
- `/backend/WorkClub.sln` — .NET 10 solution (Api, Application, Domain, Infrastructure, Tests.Unit, Tests.Integration)
|
||
- `/frontend/` — Next.js 15 App Router project with Tailwind + shadcn/ui
|
||
- `/docker-compose.yml` — Local dev stack (PostgreSQL, Keycloak, .NET API, Next.js)
|
||
- `/infra/k8s/` — Kustomize manifests (base + dev overlay)
|
||
- PostgreSQL database with RLS policies on all tenant-scoped tables
|
||
- Keycloak realm configuration with test users and club memberships
|
||
- Seed data for development
|
||
|
||
### Definition of Done
|
||
- [ ] `docker compose up` starts all 4 services healthy within 90s
|
||
- [ ] Keycloak login returns JWT with club claims
|
||
- [ ] API enforces tenant isolation (cross-tenant requests return 403)
|
||
- [ ] RLS blocks data access at DB level without tenant context
|
||
- [ ] Tasks follow 5-state workflow with invalid transitions rejected (422)
|
||
- [ ] Shifts support sign-up with capacity enforcement (409 when full)
|
||
- [ ] Frontend shows club-switcher, task list, shift list
|
||
- [ ] `dotnet test` passes all unit + integration tests
|
||
- [ ] `bun run test` passes all frontend tests
|
||
- [ ] `kustomize build infra/k8s/overlays/dev` produces valid YAML
|
||
|
||
### Must Have
|
||
- Credential-based multi-tenancy (JWT claims + X-Tenant-Id header)
|
||
- PostgreSQL RLS with `SET LOCAL` for connection pooling safety
|
||
- Finbuckle.MultiTenant with ClaimStrategy + HeaderStrategy fallback
|
||
- Backend validates JWT `clubs` claim against X-Tenant-Id → 403 on mismatch
|
||
- 4-role authorization per club (Admin, Manager, Member, Viewer)
|
||
- 5-state task workflow with state machine validation
|
||
- Time-slot shift scheduling with capacity and first-come-first-served sign-up
|
||
- Club-switcher in frontend for multi-club users
|
||
- EF Core concurrency tokens on Task and Shift entities
|
||
- Seed data: 2 clubs, 5 users, sample tasks + shifts
|
||
- Docker Compose with hot reload for .NET and Next.js
|
||
- Kubernetes Kustomize manifests (base + dev overlay)
|
||
- TDD: all backend features have tests BEFORE implementation
|
||
|
||
### Must NOT Have (Guardrails)
|
||
- **No CQRS/MediatR** — Direct service injection from controllers/endpoints
|
||
- **No generic repository pattern** — Use DbContext directly (EF Core IS the unit of work)
|
||
- **No event sourcing** — Enum column + domain validation for task states
|
||
- **No Finbuckle `IsMultiTenant()` shadow property** — Explicit `TenantId` column + RLS only
|
||
- **No Swashbuckle** — Use .NET 10 built-in `AddOpenApi()`
|
||
- **No abstraction over shadcn/ui** — Use components as-is
|
||
- **No custom HTTP client wrapper** — Use `fetch` with auth headers
|
||
- **No database-per-tenant or schema-per-tenant** — Single DB, shared schema, RLS
|
||
- **No social login, self-registration, custom Keycloak themes, or 2FA**
|
||
- **No recurring shifts, waitlists, swap requests, or approval flows**
|
||
- **No custom roles or field-level permissions** — 4 fixed roles
|
||
- **No notifications (email/push)** — Users check the app
|
||
- **No API versioning, rate limiting, or HATEOAS** — MVP scope
|
||
- **No in-memory database for tests** — Real PostgreSQL via Testcontainers
|
||
- **No billing, subscriptions, or analytics dashboard**
|
||
- **No mobile app**
|
||
|
||
---
|
||
|
||
## Verification Strategy
|
||
|
||
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
|
||
> Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN.
|
||
|
||
### Test Decision
|
||
- **Infrastructure exists**: NO (greenfield)
|
||
- **Automated tests**: YES (TDD — tests first)
|
||
- **Backend framework**: xUnit + Testcontainers (postgres:16-alpine) + WebApplicationFactory
|
||
- **Frontend framework**: Vitest + React Testing Library
|
||
- **E2E framework**: Playwright
|
||
- **If TDD**: Each task follows RED (failing test) → GREEN (minimal impl) → REFACTOR
|
||
|
||
### QA Policy
|
||
Every task MUST include agent-executed QA scenarios (see TODO template below).
|
||
Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`.
|
||
|
||
- **Frontend/UI**: Use Playwright (playwright skill) — Navigate, interact, assert DOM, screenshot
|
||
- **TUI/CLI**: Use interactive_bash (tmux) — Run command, send keystrokes, validate output
|
||
- **API/Backend**: Use Bash (curl) — Send requests, assert status + response fields
|
||
- **Library/Module**: Use Bash (dotnet test / bun test) — Run tests, compare output
|
||
- **Infrastructure**: Use Bash (docker compose / kustomize build) — Validate configs
|
||
|
||
---
|
||
|
||
## Execution Strategy
|
||
|
||
### Parallel Execution Waves
|
||
|
||
```
|
||
Wave 1 (Start Immediately — scaffolding + infrastructure):
|
||
├── Task 1: Monorepo structure + git init + solution scaffold [quick]
|
||
├── Task 2: Docker Compose (PostgreSQL + Keycloak) [quick]
|
||
├── Task 3: Keycloak realm configuration + test users [unspecified-high]
|
||
├── Task 4: Domain entities + value objects [quick]
|
||
├── Task 5: Next.js project scaffold + Tailwind + shadcn/ui [quick]
|
||
└── Task 6: Kustomize base manifests [quick]
|
||
|
||
Wave 2 (After Wave 1 — data layer + auth):
|
||
├── Task 7: PostgreSQL schema + EF Core migrations + RLS policies (depends: 1, 2, 4) [deep]
|
||
├── Task 8: Finbuckle multi-tenant middleware + tenant validation (depends: 1, 3) [deep]
|
||
├── Task 9: Keycloak JWT auth in .NET + role-based authorization (depends: 1, 3) [deep]
|
||
├── Task 10: NextAuth.js Keycloak integration (depends: 3, 5) [unspecified-high]
|
||
├── Task 11: Seed data script (depends: 2, 3, 4) [quick]
|
||
└── Task 12: Backend test infrastructure (xUnit + Testcontainers + WebApplicationFactory) (depends: 1) [unspecified-high]
|
||
|
||
Wave 3 (After Wave 2 — core API):
|
||
├── Task 13: RLS integration tests — multi-tenant isolation proof (depends: 7, 8, 12) [deep]
|
||
├── Task 14: Task CRUD API endpoints + 5-state workflow (depends: 7, 8, 9) [deep]
|
||
├── Task 15: Shift CRUD API + sign-up/cancel endpoints (depends: 7, 8, 9) [deep]
|
||
├── Task 16: Club + Member API endpoints (depends: 7, 8, 9) [unspecified-high]
|
||
└── Task 17: Frontend test infrastructure (Vitest + RTL + Playwright) (depends: 5) [quick]
|
||
|
||
Wave 4 (After Wave 3 — frontend pages):
|
||
├── Task 18: App layout + club-switcher + auth guard (depends: 10, 16, 17) [visual-engineering]
|
||
├── Task 19: Task list + task detail + status transitions UI (depends: 14, 17, 18) [visual-engineering]
|
||
├── Task 20: Shift list + shift detail + sign-up UI (depends: 15, 17, 18) [visual-engineering]
|
||
└── Task 21: Login page + first-login club picker (depends: 10, 17) [visual-engineering]
|
||
|
||
Wave 5 (After Wave 4 — polish + Docker):
|
||
├── Task 22: Docker Compose full stack (backend + frontend + hot reload) (depends: 14, 15, 18) [unspecified-high]
|
||
├── Task 23: Backend Dockerfiles (dev + prod multi-stage) (depends: 14) [quick]
|
||
├── Task 24: Frontend Dockerfiles (dev + prod standalone) (depends: 18) [quick]
|
||
└── Task 25: Kustomize dev overlay + resource limits + health checks (depends: 6, 23, 24) [unspecified-high]
|
||
|
||
Wave 6 (After Wave 5 — E2E + integration):
|
||
├── Task 26: Playwright E2E tests — auth flow + club switching (depends: 21, 22) [unspecified-high]
|
||
├── Task 27: Playwright E2E tests — task management flow (depends: 19, 22) [unspecified-high]
|
||
└── Task 28: Playwright E2E tests — shift sign-up flow (depends: 20, 22) [unspecified-high]
|
||
|
||
Wave FINAL (After ALL tasks — independent review, 4 parallel):
|
||
├── Task F1: Plan compliance audit (oracle)
|
||
├── Task F2: Code quality review (unspecified-high)
|
||
├── Task F3: Real manual QA (unspecified-high)
|
||
└── Task F4: Scope fidelity check (deep)
|
||
|
||
Critical Path: Task 1 → Task 7 → Task 8 → Task 13 → Task 14 → Task 18 → Task 22 → Task 26 → F1-F4
|
||
Parallel Speedup: ~65% faster than sequential
|
||
Max Concurrent: 6 (Wave 1)
|
||
```
|
||
|
||
### Dependency Matrix
|
||
|
||
| Task | Depends On | Blocks | Wave |
|
||
|------|-----------|--------|------|
|
||
| 1 | — | 7, 8, 9, 12 | 1 |
|
||
| 2 | — | 7, 11, 22 | 1 |
|
||
| 3 | — | 8, 9, 10, 11 | 1 |
|
||
| 4 | — | 7, 11 | 1 |
|
||
| 5 | — | 10, 17 | 1 |
|
||
| 6 | — | 25 | 1 |
|
||
| 7 | 1, 2, 4 | 13, 14, 15, 16 | 2 |
|
||
| 8 | 1, 3 | 13, 14, 15, 16 | 2 |
|
||
| 9 | 1, 3 | 14, 15, 16 | 2 |
|
||
| 10 | 3, 5 | 18, 21 | 2 |
|
||
| 11 | 2, 3, 4 | 22 | 2 |
|
||
| 12 | 1 | 13 | 2 |
|
||
| 13 | 7, 8, 12 | 14, 15 | 3 |
|
||
| 14 | 7, 8, 9 | 19, 22, 23 | 3 |
|
||
| 15 | 7, 8, 9 | 20, 22 | 3 |
|
||
| 16 | 7, 8, 9 | 18 | 3 |
|
||
| 17 | 5 | 18, 19, 20, 21 | 3 |
|
||
| 18 | 10, 16, 17 | 19, 20, 22 | 4 |
|
||
| 19 | 14, 17, 18 | 27 | 4 |
|
||
| 20 | 15, 17, 18 | 28 | 4 |
|
||
| 21 | 10, 17 | 26 | 4 |
|
||
| 22 | 14, 15, 18 | 26, 27, 28 | 5 |
|
||
| 23 | 14 | 25 | 5 |
|
||
| 24 | 18 | 25 | 5 |
|
||
| 25 | 6, 23, 24 | — | 5 |
|
||
| 26 | 21, 22 | — | 6 |
|
||
| 27 | 19, 22 | — | 6 |
|
||
| 28 | 20, 22 | — | 6 |
|
||
| F1-F4 | ALL | — | FINAL |
|
||
|
||
### Agent Dispatch Summary
|
||
|
||
- **Wave 1 (6 tasks)**: T1 → `quick`, T2 → `quick`, T3 → `unspecified-high`, T4 → `quick`, T5 → `quick`, T6 → `quick`
|
||
- **Wave 2 (6 tasks)**: T7 → `deep`, T8 → `deep`, T9 → `deep`, T10 → `unspecified-high`, T11 → `quick`, T12 → `unspecified-high`
|
||
- **Wave 3 (5 tasks)**: T13 → `deep`, T14 → `deep`, T15 → `deep`, T16 → `unspecified-high`, T17 → `quick`
|
||
- **Wave 4 (4 tasks)**: T18 → `visual-engineering`, T19 → `visual-engineering`, T20 → `visual-engineering`, T21 → `visual-engineering`
|
||
- **Wave 5 (4 tasks)**: T22 → `unspecified-high`, T23 → `quick`, T24 → `quick`, T25 → `unspecified-high`
|
||
- **Wave 6 (3 tasks)**: T26 → `unspecified-high`, T27 → `unspecified-high`, T28 → `unspecified-high`
|
||
- **FINAL (4 tasks)**: F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep`
|
||
|
||
---
|
||
|
||
## TODOs
|
||
|
||
- [x] 1. Monorepo Structure + Git Repository + .NET Solution Scaffold
|
||
|
||
**What to do**:
|
||
- Initialize git repository: `git init` at repo root
|
||
- Create monorepo directory structure: `/backend/`, `/frontend/`, `/infra/`
|
||
- Initialize .NET 10 solution at `/backend/WorkClub.sln`
|
||
- Create projects: `WorkClub.Api` (web), `WorkClub.Application` (classlib), `WorkClub.Domain` (classlib), `WorkClub.Infrastructure` (classlib), `WorkClub.Tests.Unit` (xunit), `WorkClub.Tests.Integration` (xunit)
|
||
- Configure project references: Api → Application → Domain ← Infrastructure. Tests → all.
|
||
- Add `.gitignore` (combined dotnet + node + IDE entries), `.editorconfig` (C# conventions), `global.json` (pin .NET 10 SDK)
|
||
- Create initial commit with repo structure and configuration files
|
||
- Add NuGet packages to Api: `Npgsql.EntityFrameworkCore.PostgreSQL`, `Finbuckle.MultiTenant`, `Microsoft.AspNetCore.Authentication.JwtBearer`
|
||
- Add NuGet packages to Infrastructure: `Npgsql.EntityFrameworkCore.PostgreSQL`, `Microsoft.EntityFrameworkCore.Design`
|
||
- Add NuGet packages to Tests.Integration: `Testcontainers.PostgreSql`, `Microsoft.AspNetCore.Mvc.Testing`
|
||
- Verify `dotnet build` compiles with zero errors
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add MediatR, AutoMapper, or FluentValidation
|
||
- Do NOT create generic repository interfaces
|
||
- Do NOT add Swashbuckle — will use built-in `AddOpenApi()` later
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Straightforward scaffolding with well-known dotnet CLI commands
|
||
- **Skills**: []
|
||
- No special skills needed — standard dotnet CLI operations
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 1 (with Tasks 2, 3, 4, 5, 6)
|
||
- **Blocks**: Tasks 7, 8, 9, 12
|
||
- **Blocked By**: None
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- None (greenfield — no existing code)
|
||
|
||
**External References**:
|
||
- .NET 10 SDK: `dotnet new sln`, `dotnet new webapi`, `dotnet new classlib`, `dotnet new xunit`
|
||
- Clean Architecture layout: Api → Application → Domain ← Infrastructure (standard .NET layering)
|
||
- `global.json` format: `{ "sdk": { "version": "10.0.100", "rollForward": "latestFeature" } }`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Git repository initialized
|
||
Tool: Bash
|
||
Preconditions: Repo root exists
|
||
Steps:
|
||
1. Run `git rev-parse --is-inside-work-tree`
|
||
2. Assert output is "true"
|
||
3. Run `git log --oneline -1`
|
||
4. Assert initial commit exists
|
||
5. Run `cat .gitignore`
|
||
6. Assert contains "bin/", "obj/", "node_modules/", ".next/"
|
||
Expected Result: Git repo initialized with comprehensive .gitignore
|
||
Failure Indicators: Not a git repo, missing .gitignore entries
|
||
Evidence: .sisyphus/evidence/task-1-git-init.txt
|
||
|
||
Scenario: Solution builds successfully
|
||
Tool: Bash
|
||
Preconditions: .NET 10 SDK installed
|
||
Steps:
|
||
1. Run `dotnet build backend/WorkClub.sln --configuration Release`
|
||
2. Check exit code is 0
|
||
3. Verify all 6 projects are listed in build output
|
||
Expected Result: Exit code 0, output contains "6 succeeded, 0 failed"
|
||
Failure Indicators: Any "error" or non-zero exit code
|
||
Evidence: .sisyphus/evidence/task-1-solution-build.txt
|
||
|
||
Scenario: Project references are correct
|
||
Tool: Bash
|
||
Preconditions: Solution exists
|
||
Steps:
|
||
1. Run `dotnet list backend/src/WorkClub.Api/WorkClub.Api.csproj reference`
|
||
2. Assert output contains "WorkClub.Application" and "WorkClub.Infrastructure"
|
||
3. Run `dotnet list backend/src/WorkClub.Application/WorkClub.Application.csproj reference`
|
||
4. Assert output contains "WorkClub.Domain"
|
||
5. Run `dotnet list backend/src/WorkClub.Infrastructure/WorkClub.Infrastructure.csproj reference`
|
||
6. Assert output contains "WorkClub.Domain"
|
||
Expected Result: All project references match Clean Architecture dependency graph
|
||
Failure Indicators: Missing references or circular dependencies
|
||
Evidence: .sisyphus/evidence/task-1-project-refs.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `chore(scaffold): initialize git repo and monorepo with .NET solution`
|
||
- Files: `backend/**/*.csproj`, `backend/WorkClub.sln`, `.gitignore`, `.editorconfig`, `global.json`
|
||
- Pre-commit: `dotnet build backend/WorkClub.sln`
|
||
|
||
- [x] 2. Docker Compose — PostgreSQL + Keycloak
|
||
|
||
**What to do**:
|
||
- Create `/docker-compose.yml` at repo root with services:
|
||
- `postgres`: PostgreSQL 16 Alpine, port 5432, volume `postgres-data`, healthcheck via `pg_isready`
|
||
- `keycloak`: Keycloak 26.x (quay.io/keycloak/keycloak:26.1), port 8080, depends on postgres, `--import-realm` flag with volume mount to `/opt/keycloak/data/import`
|
||
- Configure PostgreSQL: database `workclub`, user `app`, password `devpass` (dev only)
|
||
- Configure Keycloak: admin user `admin`/`admin`, PostgreSQL as Keycloak's own database (separate DB `keycloak`)
|
||
- Create placeholder realm file at `/infra/keycloak/realm-export.json` (will be populated in Task 3)
|
||
- Add `app-network` bridge network connecting all services
|
||
- Verify both services start and pass health checks
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add backend or frontend services yet (Task 22)
|
||
- Do NOT use deprecated `KEYCLOAK_IMPORT` env var — use `--import-realm` CLI flag
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Standard Docker Compose YAML, well-documented services
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 1 (with Tasks 1, 3, 4, 5, 6)
|
||
- **Blocks**: Tasks 7, 11, 22
|
||
- **Blocked By**: None
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Keycloak Docker: `quay.io/keycloak/keycloak:26.1` with `start-dev` command for local
|
||
- Keycloak realm import: `--import-realm` flag + volume `/opt/keycloak/data/import`
|
||
- PostgreSQL healthcheck: `pg_isready -U app -d workclub`
|
||
- Keycloak healthcheck: `curl -sf http://localhost:8080/health/ready || exit 1`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: PostgreSQL starts and accepts connections
|
||
Tool: Bash
|
||
Preconditions: Docker installed, no conflicting port 5432
|
||
Steps:
|
||
1. Run `docker compose up -d postgres`
|
||
2. Wait up to 30s: `docker compose exec postgres pg_isready -U app -d workclub`
|
||
3. Assert exit code 0
|
||
4. Run `docker compose exec postgres psql -U app -d workclub -c "SELECT 1"`
|
||
5. Assert output contains "1"
|
||
Expected Result: PostgreSQL healthy and accepting queries
|
||
Failure Indicators: pg_isready fails, connection refused
|
||
Evidence: .sisyphus/evidence/task-2-postgres-health.txt
|
||
|
||
Scenario: Keycloak starts and serves OIDC discovery
|
||
Tool: Bash
|
||
Preconditions: docker compose up -d (postgres + keycloak)
|
||
Steps:
|
||
1. Run `docker compose up -d`
|
||
2. Wait up to 120s for Keycloak health: poll `curl -sf http://localhost:8080/health/ready`
|
||
3. Curl `http://localhost:8080/realms/master/.well-known/openid-configuration`
|
||
4. Assert HTTP 200 and JSON contains "issuer" field
|
||
Expected Result: Keycloak healthy, OIDC endpoint responding
|
||
Failure Indicators: Keycloak fails to start, OIDC endpoint returns non-200
|
||
Evidence: .sisyphus/evidence/task-2-keycloak-health.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `infra(docker): add Docker Compose with PostgreSQL and Keycloak`
|
||
- Files: `docker-compose.yml`, `infra/keycloak/realm-export.json`
|
||
- Pre-commit: `docker compose config`
|
||
|
||
- [x] 3. Keycloak Realm Configuration + Test Users
|
||
|
||
**What to do**:
|
||
- Create Keycloak realm `workclub` with:
|
||
- Client `workclub-api` (confidential, service account enabled) for backend
|
||
- Client `workclub-app` (public, PKCE) for frontend — redirect URIs: `http://localhost:3000/*`
|
||
- Custom user attribute `clubs` (JSON string: `{"club-1-uuid": "admin", "club-2-uuid": "member"}`)
|
||
- Custom protocol mapper `club-membership` (type: `Script Mapper` or `User Attribute` → JWT claim `clubs`)
|
||
- Create 5 test users:
|
||
- `admin@test.com` / `testpass123` — Admin of club-1, Member of club-2
|
||
- `manager@test.com` / `testpass123` — Manager of club-1
|
||
- `member1@test.com` / `testpass123` — Member of club-1, Member of club-2
|
||
- `member2@test.com` / `testpass123` — Member of club-1
|
||
- `viewer@test.com` / `testpass123` — Viewer of club-1
|
||
- Export realm to `/infra/keycloak/realm-export.json` including users (with hashed passwords)
|
||
- Test: obtain token for admin@test.com, decode JWT, verify `clubs` claim is present
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add social login providers
|
||
- Do NOT add self-registration flow
|
||
- Do NOT customize Keycloak login theme
|
||
- Do NOT use Keycloak Organizations feature (too new for MVP)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: Keycloak realm configuration requires understanding OIDC flows, custom mappers, and realm export format
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 1 (with Tasks 1, 2, 4, 5, 6)
|
||
- **Blocks**: Tasks 8, 9, 10, 11
|
||
- **Blocked By**: None (creates realm export JSON file; Docker Compose from Task 2 will use it, but the file can be created independently)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Keycloak Admin REST API: `https://www.keycloak.org/docs-api/latest/rest-api/` — for programmatic realm creation
|
||
- Keycloak realm export format: JSON with `realm`, `clients`, `users`, `protocolMappers` arrays
|
||
- Custom protocol mapper: `oidc-usermodel-attribute-mapper` type with `claim.name: clubs`, `user.attribute: clubs`
|
||
- PKCE client config: `publicClient: true`, `standardFlowEnabled: true`, `directAccessGrantsEnabled: true` (for dev token testing)
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Token contains club membership claims
|
||
Tool: Bash
|
||
Preconditions: docker compose up -d (with realm imported)
|
||
Steps:
|
||
1. Obtain token: `curl -sf -X POST http://localhost:8080/realms/workclub/protocol/openid-connect/token -d "client_id=workclub-app&username=admin@test.com&password=testpass123&grant_type=password"`
|
||
2. Extract access_token from JSON response
|
||
3. Decode JWT payload: `echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq '.clubs'`
|
||
4. Assert clubs claim contains `{"club-1-uuid": "admin", "club-2-uuid": "member"}`
|
||
Expected Result: JWT has `clubs` claim with correct role mappings
|
||
Failure Indicators: Missing `clubs` claim, wrong roles, token request fails
|
||
Evidence: .sisyphus/evidence/task-3-jwt-claims.txt
|
||
|
||
Scenario: All 5 test users can authenticate
|
||
Tool: Bash
|
||
Preconditions: Keycloak running with realm imported
|
||
Steps:
|
||
1. For each user (admin@test.com, manager@test.com, member1@test.com, member2@test.com, viewer@test.com):
|
||
curl token endpoint with username/password
|
||
2. Assert HTTP 200 for all 5 users
|
||
3. Assert each response contains "access_token" field
|
||
Expected Result: All 5 users authenticate successfully
|
||
Failure Indicators: Any user returns non-200, missing access_token
|
||
Evidence: .sisyphus/evidence/task-3-user-auth.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `infra(keycloak): configure realm with test users and club memberships`
|
||
- Files: `infra/keycloak/realm-export.json`
|
||
- Pre-commit: —
|
||
|
||
---
|
||
|
||
- [x] 4. Domain Entities + Value Objects
|
||
|
||
**What to do**:
|
||
- Create domain entities in `WorkClub.Domain/Entities/`:
|
||
- `Club`: Id (Guid), TenantId (string), Name, SportType (enum), Description, CreatedAt, UpdatedAt
|
||
- `Member`: Id (Guid), TenantId (string), ExternalUserId (string — Keycloak sub), DisplayName, Email, Role (enum: Admin/Manager/Member/Viewer), ClubId (FK), CreatedAt, UpdatedAt
|
||
- `WorkItem` (Task): Id (Guid), TenantId (string), Title, Description, Status (enum: Open/Assigned/InProgress/Review/Done), AssigneeId (FK? nullable), CreatedById (FK), ClubId (FK), DueDate (DateTimeOffset?), CreatedAt, UpdatedAt, RowVersion (byte[] — concurrency token)
|
||
- `Shift`: Id (Guid), TenantId (string), Title, Description, Location (string?), StartTime (DateTimeOffset), EndTime (DateTimeOffset), Capacity (int, default 1), ClubId (FK), CreatedById (FK), CreatedAt, UpdatedAt, RowVersion (byte[])
|
||
- `ShiftSignup`: Id (Guid), ShiftId (FK), MemberId (FK), SignedUpAt (DateTimeOffset)
|
||
- Create enums in `WorkClub.Domain/Enums/`:
|
||
- `SportType`: Tennis, Cycling, Swimming, Football, Other
|
||
- `ClubRole`: Admin, Manager, Member, Viewer
|
||
- `WorkItemStatus`: Open, Assigned, InProgress, Review, Done
|
||
- Create `ITenantEntity` marker interface: `string TenantId { get; set; }`
|
||
- Create `WorkItemStatus` state machine validation method on `WorkItem` entity:
|
||
- Valid transitions: Open→Assigned, Assigned→InProgress, InProgress→Review, Review→Done, Review→InProgress
|
||
- Method: `bool CanTransitionTo(WorkItemStatus newStatus)` + `void TransitionTo(WorkItemStatus newStatus)` (throws on invalid)
|
||
- Write unit tests FIRST (TDD): test state machine — valid transitions succeed, invalid transitions throw
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add event sourcing or domain events
|
||
- Do NOT create repository interfaces — will use DbContext directly
|
||
- Do NOT add navigation properties yet (EF Core configuration is Task 7)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Simple POCO classes and enum definitions with straightforward unit tests
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 1 (with Tasks 1, 2, 3, 5, 6)
|
||
- **Blocks**: Tasks 7, 11
|
||
- **Blocked By**: None
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- C# 14 record types for value objects: `public record SportType(string Value)`
|
||
- State machine pattern: simple `switch` expression in entity method — no external library needed
|
||
- `ITenantEntity` interface pattern from Metis research: explicit TenantId property (not Finbuckle shadow)
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**If TDD:**
|
||
- [ ] Test file created: `backend/tests/WorkClub.Tests.Unit/Domain/WorkItemStatusTests.cs`
|
||
- [ ] `dotnet test backend/tests/WorkClub.Tests.Unit` → PASS (all state machine tests)
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Valid state transitions succeed
|
||
Tool: Bash
|
||
Preconditions: Unit test project compiles
|
||
Steps:
|
||
1. Run `dotnet test backend/tests/WorkClub.Tests.Unit --filter "WorkItemStatus" --verbosity normal`
|
||
2. Assert tests cover: Open→Assigned, Assigned→InProgress, InProgress→Review, Review→Done, Review→InProgress
|
||
3. Assert all pass
|
||
Expected Result: 5 valid transition tests pass
|
||
Failure Indicators: Any test fails, missing test cases
|
||
Evidence: .sisyphus/evidence/task-4-state-machine-valid.txt
|
||
|
||
Scenario: Invalid state transitions throw
|
||
Tool: Bash
|
||
Preconditions: Unit test project compiles
|
||
Steps:
|
||
1. Run `dotnet test backend/tests/WorkClub.Tests.Unit --filter "InvalidTransition" --verbosity normal`
|
||
2. Assert tests cover: Open→Done, Open→InProgress, Assigned→Done, InProgress→Open, Done→anything
|
||
3. Assert all pass (each asserts exception thrown)
|
||
Expected Result: Invalid transition tests pass (exceptions correctly thrown)
|
||
Failure Indicators: Missing invalid transition tests, test failures
|
||
Evidence: .sisyphus/evidence/task-4-state-machine-invalid.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `feat(domain): add core entities — Club, Member, WorkItem, Shift with state machine`
|
||
- Files: `backend/src/WorkClub.Domain/**/*.cs`, `backend/tests/WorkClub.Tests.Unit/Domain/*.cs`
|
||
- Pre-commit: `dotnet test backend/tests/WorkClub.Tests.Unit`
|
||
|
||
- [x] 5. Next.js Project Scaffold + Tailwind + shadcn/ui
|
||
|
||
**What to do**:
|
||
- Initialize Next.js 15 project in `/frontend/` using `bunx create-next-app@latest` with:
|
||
- App Router (not Pages), TypeScript, Tailwind CSS, ESLint, `src/` directory
|
||
- `output: 'standalone'` in `next.config.ts`
|
||
- Install and configure shadcn/ui: `bunx shadcn@latest init`
|
||
- Install initial components: Button, Card, Badge, Input, Label, Select, Dialog, DropdownMenu, Table, Toast
|
||
- Configure path aliases in `tsconfig.json`: `@/*` → `src/*`
|
||
- Create directory structure:
|
||
- `src/app/` — App Router pages
|
||
- `src/components/` — shared components
|
||
- `src/lib/` — utilities
|
||
- `src/hooks/` — custom hooks
|
||
- `src/types/` — TypeScript types
|
||
- Add environment variables to `.env.local.example`:
|
||
- `NEXT_PUBLIC_API_URL=http://localhost:5000`
|
||
- `NEXTAUTH_URL=http://localhost:3000`
|
||
- `NEXTAUTH_SECRET=dev-secret-change-me`
|
||
- `KEYCLOAK_ISSUER=http://localhost:8080/realms/workclub`
|
||
- `KEYCLOAK_CLIENT_ID=workclub-app`
|
||
- `KEYCLOAK_CLIENT_SECRET=<from-keycloak>`
|
||
- Verify `bun run build` succeeds
|
||
|
||
**Must NOT do**:
|
||
- Do NOT install Bun-specific Next.js plugins — use standard Next.js
|
||
- Do NOT create custom component wrappers over shadcn/ui
|
||
- Do NOT add auth pages yet (Task 21)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Standard Next.js scaffolding with well-known CLI tools
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 1 (with Tasks 1, 2, 3, 4, 6)
|
||
- **Blocks**: Tasks 10, 17
|
||
- **Blocked By**: None
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Next.js create-next-app: `bunx create-next-app@latest frontend --typescript --tailwind --eslint --app --src-dir --use-bun`
|
||
- shadcn/ui init: `bunx shadcn@latest init` then `bunx shadcn@latest add button card badge input label select dialog dropdown-menu table toast`
|
||
- `next.config.ts`: `{ output: 'standalone' }` for Docker deployment
|
||
- Standalone output: copies only needed `node_modules`, creates `server.js` entry point
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Next.js builds successfully
|
||
Tool: Bash
|
||
Preconditions: Bun installed, frontend/ directory exists
|
||
Steps:
|
||
1. Run `bun run build` in frontend/
|
||
2. Assert exit code 0
|
||
3. Verify `.next/standalone/server.js` exists
|
||
Expected Result: Build succeeds with standalone output
|
||
Failure Indicators: TypeScript errors, build failure, missing standalone
|
||
Evidence: .sisyphus/evidence/task-5-nextjs-build.txt
|
||
|
||
Scenario: Dev server starts
|
||
Tool: Bash
|
||
Preconditions: frontend/ exists with dependencies installed
|
||
Steps:
|
||
1. Start `bun run dev` in background
|
||
2. Wait 10s, then curl `http://localhost:3000`
|
||
3. Assert HTTP 200
|
||
4. Kill dev server
|
||
Expected Result: Dev server responds on port 3000
|
||
Failure Indicators: Server fails to start, non-200 response
|
||
Evidence: .sisyphus/evidence/task-5-dev-server.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `chore(frontend): initialize Next.js project with Tailwind and shadcn/ui`
|
||
- Files: `frontend/**`
|
||
- Pre-commit: `bun run build` (in frontend/)
|
||
|
||
- [x] 6. Kustomize Base Manifests
|
||
|
||
**What to do**:
|
||
- Create `/infra/k8s/base/kustomization.yaml` referencing all base resources
|
||
- Create base manifests:
|
||
- `backend-deployment.yaml`: Deployment for dotnet-api (1 replica, port 8080, health probes at `/health/live`, `/health/ready`, `/health/startup`)
|
||
- `backend-service.yaml`: ClusterIP Service (port 80 → 8080)
|
||
- `frontend-deployment.yaml`: Deployment for nextjs (1 replica, port 3000, health probe at `/api/health`)
|
||
- `frontend-service.yaml`: ClusterIP Service (port 80 → 3000)
|
||
- `postgres-statefulset.yaml`: StatefulSet (1 replica, port 5432, PVC 10Gi, healthcheck via `pg_isready`)
|
||
- `postgres-service.yaml`: Headless + primary ClusterIP service
|
||
- `keycloak-deployment.yaml`: Deployment (1 replica, port 8080)
|
||
- `keycloak-service.yaml`: ClusterIP Service
|
||
- `configmap.yaml`: App configuration (non-sensitive: log level, CORS origins, API URLs)
|
||
- `ingress.yaml`: Basic Ingress for single-domain routing (frontend on `/`, backend on `/api`)
|
||
- Use resource requests/limits placeholders (overridden per environment in overlays)
|
||
- Use image tag placeholder `latest` (overridden per environment)
|
||
- Verify `kustomize build infra/k8s/base` produces valid YAML
|
||
|
||
**Must NOT do**:
|
||
- Do NOT create Helm charts
|
||
- Do NOT add wildcard TLS/cert-manager (single domain, no subdomains)
|
||
- Do NOT add HPA, PDB, or NetworkPolicies (production concerns, not MVP)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Standard Kubernetes YAML manifests with well-known patterns
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 1 (with Tasks 1, 2, 3, 4, 5)
|
||
- **Blocks**: Task 25
|
||
- **Blocked By**: None
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Kustomize: `kustomization.yaml` with `resources:` list
|
||
- .NET health probes: `/health/startup` (startupProbe), `/health/live` (livenessProbe), `/health/ready` (readinessProbe)
|
||
- Next.js health: `/api/health` (custom route handler)
|
||
- PostgreSQL StatefulSet: headless Service + volumeClaimTemplates
|
||
- Ingress single-domain: path-based routing (`/` → frontend, `/api` → backend)
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Kustomize base builds valid YAML
|
||
Tool: Bash
|
||
Preconditions: kustomize CLI installed
|
||
Steps:
|
||
1. Run `kustomize build infra/k8s/base`
|
||
2. Assert exit code 0
|
||
3. Pipe output to `grep "kind:" | sort -u`
|
||
4. Assert contains: ConfigMap, Deployment (x3), Ingress, Service (x4), StatefulSet
|
||
Expected Result: Valid YAML with all expected resource kinds
|
||
Failure Indicators: kustomize build fails, missing resource kinds
|
||
Evidence: .sisyphus/evidence/task-6-kustomize-base.txt
|
||
|
||
Scenario: Resource names are consistent
|
||
Tool: Bash
|
||
Preconditions: kustomize build succeeds
|
||
Steps:
|
||
1. Run `kustomize build infra/k8s/base | grep "name:" | head -20`
|
||
2. Verify naming convention is consistent (e.g., workclub-api, workclub-frontend, workclub-postgres)
|
||
Expected Result: All resources follow consistent naming pattern
|
||
Failure Indicators: Inconsistent or missing names
|
||
Evidence: .sisyphus/evidence/task-6-resource-names.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `infra(k8s): add Kustomize base manifests for all services`
|
||
- Files: `infra/k8s/base/**/*.yaml`
|
||
- Pre-commit: `kustomize build infra/k8s/base`
|
||
|
||
---
|
||
|
||
- [x] 7. PostgreSQL Schema + EF Core Migrations + RLS Policies
|
||
|
||
**What to do**:
|
||
- Create `AppDbContext` in `WorkClub.Infrastructure/Data/`:
|
||
- DbSets: Clubs, Members, WorkItems, Shifts, ShiftSignups
|
||
- Configure entity mappings via `IEntityTypeConfiguration<T>` classes
|
||
- Configure `RowVersion` as concurrency token on WorkItem and Shift (`UseXminAsConcurrencyToken()`)
|
||
- Configure indexes: `TenantId` on all tenant-scoped tables, `ClubId` on Member/WorkItem/Shift, `Status` on WorkItem
|
||
- Configure relationships: Club → Members, Club → WorkItems, Club → Shifts, Shift → ShiftSignups, Member → ShiftSignups
|
||
- Create initial EF Core migration
|
||
- Create SQL migration script for RLS policies (run after EF migration):
|
||
- `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` on: clubs, members, work_items, shifts, shift_signups
|
||
- Create `tenant_isolation` policy on each table: `USING (tenant_id = current_setting('app.current_tenant_id', true)::text)`
|
||
- Create `bypass_rls_policy` on each table for migrations role: `USING (true)` for role `app_admin`
|
||
- Create `app_user` role (used by application) — RLS applies
|
||
- Create `app_admin` role (used by migrations) — RLS bypassed
|
||
- Create `TenantDbConnectionInterceptor` (implements `DbConnectionInterceptor`):
|
||
- On `ConnectionOpenedAsync`: execute `SET LOCAL app.current_tenant_id = '{tenantId}'`
|
||
- Get tenant ID from `IHttpContextAccessor` → `ITenantInfo` (Finbuckle)
|
||
- Create `SaveChangesInterceptor` for automatic TenantId assignment on new entities (implements `ITenantEntity`)
|
||
- Write integration tests FIRST (TDD):
|
||
- Test: migration applies cleanly to fresh PostgreSQL
|
||
- Test: RLS blocks queries without `SET LOCAL` context
|
||
- Test: RLS allows queries with correct tenant context
|
||
|
||
**Must NOT do**:
|
||
- Do NOT use Finbuckle's `IsMultiTenant()` fluent API — use explicit `TenantId` property
|
||
- Do NOT use `SET` — MUST use `SET LOCAL` for transaction-scoped tenant context
|
||
- Do NOT use in-memory database provider for tests
|
||
- Do NOT create generic repository pattern
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `deep`
|
||
- Reason: Complex EF Core configuration with RLS policies, connection interceptors, and concurrent connection pooling safety — highest-risk task in the project
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 2 (with Tasks 8, 9, 10, 11, 12)
|
||
- **Blocks**: Tasks 13, 14, 15, 16
|
||
- **Blocked By**: Tasks 1 (solution), 2 (Docker Compose for PostgreSQL), 4 (domain entities)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- EF Core Npgsql: `UseXminAsConcurrencyToken()` for PostgreSQL optimistic concurrency
|
||
- PostgreSQL RLS: `ALTER TABLE x ENABLE ROW LEVEL SECURITY; CREATE POLICY ... USING (...)`
|
||
- `SET LOCAL`: Transaction-scoped session variable — safe with connection pooling
|
||
- `current_setting('app.current_tenant_id', true)`: Second param `true` returns NULL instead of error when unset
|
||
- `DbConnectionInterceptor`: `ConnectionOpenedAsync(DbConnection, ConnectionEndEventData, CancellationToken)` — execute SQL after connection opened
|
||
- `bypass_rls_policy`: `CREATE POLICY bypass ON table FOR ALL TO app_admin USING (true)` — allows migrations
|
||
- Finbuckle `ITenantInfo.Id`: Access current tenant from DI
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**If TDD:**
|
||
- [ ] Test file: `backend/tests/WorkClub.Tests.Integration/Data/MigrationTests.cs`
|
||
- [ ] Test file: `backend/tests/WorkClub.Tests.Integration/Data/RlsTests.cs`
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Migration applies to fresh PostgreSQL
|
||
Tool: Bash (Testcontainers in test)
|
||
Preconditions: Testcontainers, Docker running
|
||
Steps:
|
||
1. Run `dotnet test backend/tests/WorkClub.Tests.Integration --filter "Migration" --verbosity normal`
|
||
2. Test spins up PostgreSQL container, applies migration, verifies all tables exist
|
||
Expected Result: All tables created (clubs, members, work_items, shifts, shift_signups)
|
||
Failure Indicators: Migration fails, missing tables
|
||
Evidence: .sisyphus/evidence/task-7-migration.txt
|
||
|
||
Scenario: RLS blocks access without tenant context
|
||
Tool: Bash (integration test)
|
||
Preconditions: Migration applied, seed data inserted
|
||
Steps:
|
||
1. Run integration test that:
|
||
a. Inserts data for tenant-1 and tenant-2 using admin role (bypass RLS)
|
||
b. Opens connection as app_user WITHOUT `SET LOCAL`
|
||
c. SELECT * FROM work_items → assert 0 rows
|
||
d. Opens connection as app_user WITH `SET LOCAL app.current_tenant_id = 'tenant-1'`
|
||
e. SELECT * FROM work_items → assert only tenant-1 rows
|
||
Expected Result: RLS correctly filters by tenant, returns 0 without context
|
||
Failure Indicators: Data leaks across tenants, queries return all rows
|
||
Evidence: .sisyphus/evidence/task-7-rls-isolation.txt
|
||
|
||
Scenario: RLS allows correct tenant access
|
||
Tool: Bash (integration test)
|
||
Preconditions: Migration applied, multi-tenant data seeded
|
||
Steps:
|
||
1. Run integration test with Testcontainers:
|
||
a. Insert 5 work items for tenant-1, 3 for tenant-2
|
||
b. SET LOCAL to tenant-1 → SELECT count(*) → assert 5
|
||
c. SET LOCAL to tenant-2 → SELECT count(*) → assert 3
|
||
Expected Result: Each tenant sees only their data
|
||
Failure Indicators: Wrong counts, cross-tenant data visible
|
||
Evidence: .sisyphus/evidence/task-7-rls-correct-access.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Task 8)
|
||
- Message: `feat(data): add EF Core DbContext, migrations, RLS policies, and multi-tenant middleware`
|
||
- Files: `backend/src/WorkClub.Infrastructure/Data/**/*.cs`, SQL migration files
|
||
- Pre-commit: `dotnet test backend/tests/WorkClub.Tests.Integration --filter "Migration|Rls"`
|
||
|
||
- [x] 8. Finbuckle Multi-Tenant Middleware + Tenant Validation
|
||
|
||
**What to do**:
|
||
- Configure Finbuckle in `Program.cs`:
|
||
- `builder.Services.AddMultiTenant<TenantInfo>().WithClaimStrategy("tenant_id").WithHeaderStrategy("X-Tenant-Id")`
|
||
- Middleware order: `UseAuthentication()` → `UseMultiTenant()` → `UseAuthorization()`
|
||
- Create `TenantValidationMiddleware`:
|
||
- Extract `clubs` claim from JWT
|
||
- Extract `X-Tenant-Id` header from request
|
||
- Validate: X-Tenant-Id MUST be present in JWT `clubs` claim → 403 if mismatch
|
||
- Set Finbuckle tenant context
|
||
- Create `ITenantProvider` service (scoped):
|
||
- `GetTenantId()`: returns current tenant ID from Finbuckle context
|
||
- `GetUserRole()`: returns user's role in current tenant from JWT clubs claim
|
||
- Register `TenantDbConnectionInterceptor` (from Task 7) in DI
|
||
- Write integration tests FIRST (TDD):
|
||
- Test: request with valid JWT + matching X-Tenant-Id → 200
|
||
- Test: request with valid JWT + non-member X-Tenant-Id → 403
|
||
- Test: request without X-Tenant-Id header → 400
|
||
- Test: unauthenticated request → 401
|
||
|
||
**Must NOT do**:
|
||
- Do NOT use Finbuckle's RouteStrategy (tenant comes from credentials, not URL)
|
||
- Do NOT use `UseMultiTenant()` before `UseAuthentication()` (ClaimStrategy needs claims)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `deep`
|
||
- Reason: Finbuckle middleware integration with custom validation, security-critical cross-tenant check
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 2 (with Tasks 7, 9, 10, 11, 12)
|
||
- **Blocks**: Tasks 13, 14, 15, 16
|
||
- **Blocked By**: Tasks 1 (solution), 3 (Keycloak for JWT claims)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Finbuckle docs: `WithClaimStrategy(claimType)` — reads tenant from specified JWT claim
|
||
- Finbuckle docs: `WithHeaderStrategy(headerName)` — reads from HTTP header as fallback
|
||
- Middleware order for single realm: `UseAuthentication()` → `UseMultiTenant()` → `UseAuthorization()`
|
||
- `IMultiTenantContextAccessor<TenantInfo>`: injected service to access resolved tenant
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**If TDD:**
|
||
- [ ] Test file: `backend/tests/WorkClub.Tests.Integration/Middleware/TenantValidationTests.cs`
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Valid tenant request succeeds
|
||
Tool: Bash (integration test with WebApplicationFactory)
|
||
Preconditions: WebApplicationFactory configured with test Keycloak token
|
||
Steps:
|
||
1. Run integration test:
|
||
a. Create JWT with clubs: {"club-1": "admin"}
|
||
b. Send GET /api/tasks with Authorization: Bearer + X-Tenant-Id: club-1
|
||
c. Assert HTTP 200
|
||
Expected Result: Request passes tenant validation
|
||
Failure Indicators: 401 or 403 when valid
|
||
Evidence: .sisyphus/evidence/task-8-valid-tenant.txt
|
||
|
||
Scenario: Cross-tenant access denied
|
||
Tool: Bash (integration test)
|
||
Preconditions: WebApplicationFactory with test JWT
|
||
Steps:
|
||
1. Run integration test:
|
||
a. Create JWT with clubs: {"club-1": "admin"} (no club-2)
|
||
b. Send GET /api/tasks with X-Tenant-Id: club-2
|
||
c. Assert HTTP 403
|
||
Expected Result: 403 Forbidden — user not member of requested club
|
||
Failure Indicators: Returns 200 (data leak) or wrong error code
|
||
Evidence: .sisyphus/evidence/task-8-cross-tenant-denied.txt
|
||
|
||
Scenario: Missing tenant header returns 400
|
||
Tool: Bash (integration test)
|
||
Preconditions: WebApplicationFactory
|
||
Steps:
|
||
1. Send authenticated request WITHOUT X-Tenant-Id header
|
||
2. Assert HTTP 400
|
||
Expected Result: 400 Bad Request
|
||
Failure Indicators: 200 or 500
|
||
Evidence: .sisyphus/evidence/task-8-missing-header.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Task 7)
|
||
- Message: `feat(data): add EF Core DbContext, migrations, RLS policies, and multi-tenant middleware`
|
||
- Files: `backend/src/WorkClub.Api/Middleware/*.cs`, `Program.cs` updates
|
||
- Pre-commit: `dotnet test backend/tests/WorkClub.Tests.Integration --filter "TenantValidation"`
|
||
|
||
---
|
||
|
||
- [x] 9. Keycloak JWT Auth in .NET + Role-Based Authorization
|
||
|
||
**What to do**:
|
||
- Configure JWT Bearer authentication in `Program.cs`:
|
||
- `builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => { options.Authority = keycloakIssuer; options.Audience = "workclub-api"; })`
|
||
- Create custom `ClaimsPrincipal` transformation:
|
||
- Parse `clubs` claim from JWT → extract role for current tenant → add role claims
|
||
- Map club role to ASP.NET `ClaimTypes.Role` (e.g., "Admin", "Manager", "Member", "Viewer")
|
||
- Create authorization policies:
|
||
- `RequireAdmin`: requires "Admin" role
|
||
- `RequireManager`: requires "Admin" OR "Manager" role
|
||
- `RequireMember`: requires "Admin" OR "Manager" OR "Member" role
|
||
- `RequireViewer`: any authenticated user with valid club membership
|
||
- Add `builder.Services.AddOpenApi()` for API documentation
|
||
- Map health check endpoints: `/health/live`, `/health/ready`, `/health/startup`
|
||
- Write integration tests FIRST (TDD):
|
||
- Test: Admin can access admin-only endpoints
|
||
- Test: Member cannot access admin-only endpoints → 403
|
||
- Test: Viewer can only read, not write → 403 on POST/PUT/DELETE
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add Swashbuckle — use `AddOpenApi()` built-in
|
||
- Do NOT implement custom JWT validation — let ASP.NET handle it
|
||
- Do NOT add 2FA or password policies
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `deep`
|
||
- Reason: JWT authentication with custom claims transformation and role-based authorization requires careful security implementation
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 2 (with Tasks 7, 8, 10, 11, 12)
|
||
- **Blocks**: Tasks 14, 15, 16
|
||
- **Blocked By**: Tasks 1 (solution), 3 (Keycloak config)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- ASP.NET JWT Bearer: `AddJwtBearer` with `Authority` pointing to Keycloak realm
|
||
- .NET 10 OpenAPI: `builder.Services.AddOpenApi()` + `app.MapOpenApi()`
|
||
- Claims transformation: `IClaimsTransformation.TransformAsync(ClaimsPrincipal)`
|
||
- Authorization policies: `builder.Services.AddAuthorizationBuilder().AddPolicy("RequireAdmin", p => p.RequireRole("Admin"))`
|
||
- Health checks: `builder.Services.AddHealthChecks().AddNpgSql(connectionString)`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**If TDD:**
|
||
- [ ] Test file: `backend/tests/WorkClub.Tests.Integration/Auth/AuthorizationTests.cs`
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Role-based access control works
|
||
Tool: Bash (integration test)
|
||
Preconditions: WebApplicationFactory with mocked JWT claims
|
||
Steps:
|
||
1. Test: JWT with Admin role → GET /api/clubs → 200
|
||
2. Test: JWT with Viewer role → POST /api/tasks → 403
|
||
3. Test: JWT with Manager role → POST /api/tasks → 200
|
||
4. Test: No token → any endpoint → 401
|
||
Expected Result: Each role gets correct access level
|
||
Failure Indicators: Wrong HTTP status codes, privilege escalation
|
||
Evidence: .sisyphus/evidence/task-9-rbac.txt
|
||
|
||
Scenario: Health endpoints respond without auth
|
||
Tool: Bash
|
||
Preconditions: API running
|
||
Steps:
|
||
1. curl http://localhost:5000/health/live → assert 200
|
||
2. curl http://localhost:5000/health/ready → assert 200
|
||
Expected Result: Health endpoints are public (no auth required)
|
||
Failure Indicators: 401 on health endpoints
|
||
Evidence: .sisyphus/evidence/task-9-health.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `feat(auth): add Keycloak JWT authentication and role-based authorization`
|
||
- Files: `backend/src/WorkClub.Api/Auth/*.cs`, `Program.cs` updates
|
||
- Pre-commit: `dotnet test backend/tests/WorkClub.Tests.Integration --filter "Authorization"`
|
||
|
||
- [x] 10. NextAuth.js Keycloak Integration
|
||
|
||
**What to do**:
|
||
- Install Auth.js v5: `bun add next-auth@beta @auth/core`
|
||
- Create `/frontend/src/auth/` directory with:
|
||
- `auth.ts`: NextAuth config with Keycloak OIDC provider
|
||
- Configure JWT callback to include `clubs` claim from Keycloak token
|
||
- Configure session callback to expose clubs + active club to client
|
||
- Create `/frontend/src/middleware.ts`:
|
||
- Protect all routes except `/`, `/login`, `/api/auth/*`
|
||
- Redirect unauthenticated users to `/login`
|
||
- Create auth utility hooks:
|
||
- `useSession()` wrapper that includes club membership
|
||
- `useActiveClub()` — reads from local storage / cookie, provides club context
|
||
- Create API fetch utility:
|
||
- Wraps `fetch` with `Authorization: Bearer <token>` and `X-Tenant-Id: <activeClub>` headers
|
||
- Used by all frontend API calls
|
||
- Write tests FIRST (TDD):
|
||
- Test: `useActiveClub` returns stored club
|
||
- Test: API fetch utility includes correct headers
|
||
|
||
**Must NOT do**:
|
||
- Do NOT create custom HTTP client class wrapper
|
||
- Do NOT add social login providers
|
||
- Do NOT store tokens in localStorage (use HTTP-only cookies via NextAuth)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: Auth.js v5 configuration with OIDC + custom callbacks requires understanding of token flows
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 2 (with Tasks 7, 8, 9, 11, 12)
|
||
- **Blocks**: Tasks 18, 21
|
||
- **Blocked By**: Tasks 3 (Keycloak realm), 5 (Next.js scaffold)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Auth.js v5 Keycloak: `import KeycloakProvider from "next-auth/providers/keycloak"`
|
||
- Auth.js JWT callback: `jwt({ token, account }) → add clubs claim from account.access_token`
|
||
- Auth.js session callback: `session({ session, token }) → expose clubs to client`
|
||
- Next.js middleware: `export { auth as middleware } from "@/auth"` — or custom matcher
|
||
- HTTP-only cookies: Default in Auth.js — tokens never exposed to client JS
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**If TDD:**
|
||
- [ ] Test file: `frontend/src/hooks/__tests__/useActiveClub.test.ts`
|
||
- [ ] Test file: `frontend/src/lib/__tests__/api.test.ts`
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Auth utility includes correct headers
|
||
Tool: Bash (bun test)
|
||
Preconditions: Frontend tests configured
|
||
Steps:
|
||
1. Run `bun run test -- --filter "api fetch"` in frontend/
|
||
2. Assert: test verifies Authorization header contains Bearer token
|
||
3. Assert: test verifies X-Tenant-Id header contains active club ID
|
||
Expected Result: API utility correctly attaches auth and tenant headers
|
||
Failure Indicators: Missing headers, wrong header names
|
||
Evidence: .sisyphus/evidence/task-10-api-headers.txt
|
||
|
||
Scenario: Protected routes redirect to login
|
||
Tool: Bash (bun test)
|
||
Preconditions: Frontend middleware configured
|
||
Steps:
|
||
1. Test middleware: unauthenticated request to /dashboard → redirect to /login
|
||
2. Test middleware: request to /api/auth/* → pass through
|
||
Expected Result: Middleware protects routes correctly
|
||
Failure Indicators: Protected route accessible without auth
|
||
Evidence: .sisyphus/evidence/task-10-middleware.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `feat(frontend-auth): add NextAuth.js Keycloak integration`
|
||
- Files: `frontend/src/auth/**`, `frontend/src/middleware.ts`, `frontend/src/lib/api.ts`, `frontend/src/hooks/useActiveClub.ts`
|
||
- Pre-commit: `bun run build` (in frontend/)
|
||
|
||
- [x] 11. Seed Data Script
|
||
|
||
**What to do**:
|
||
- Create `SeedDataService` in `WorkClub.Infrastructure/Seed/`:
|
||
- Seed 2 clubs:
|
||
- Club 1: "Sunrise Tennis Club" (Tennis), tenant_id: "club-1-uuid"
|
||
- Club 2: "Valley Cycling Club" (Cycling), tenant_id: "club-2-uuid"
|
||
- Seed 5 members (matching Keycloak test users):
|
||
- Admin user → Admin in Club 1, Member in Club 2
|
||
- Manager user → Manager in Club 1
|
||
- Member1 user → Member in Club 1, Member in Club 2
|
||
- Member2 user → Member in Club 1
|
||
- Viewer user → Viewer in Club 1
|
||
- Seed sample work items per club:
|
||
- Club 1: 5 tasks in various states (Open, Assigned, InProgress, Review, Done)
|
||
- Club 2: 3 tasks (Open, Assigned, InProgress)
|
||
- Seed sample shifts per club:
|
||
- Club 1: 3 shifts (past, today, future) with varying capacity and sign-ups
|
||
- Club 2: 2 shifts (today, future)
|
||
- Register seed service to run on startup in Development environment only
|
||
- Use `app_admin` role connection for seeding (bypasses RLS)
|
||
- Make seed idempotent (check if data exists before inserting)
|
||
|
||
**Must NOT do**:
|
||
- Do NOT seed in Production — guard with `IHostEnvironment.IsDevelopment()`
|
||
- Do NOT hard-code GUIDs — generate deterministic GUIDs from names for consistency
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Straightforward data insertion with known entities
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 2 (with Tasks 7, 8, 9, 10, 12)
|
||
- **Blocks**: Task 22
|
||
- **Blocked By**: Tasks 2 (Docker PostgreSQL), 3 (Keycloak users), 4 (domain entities)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- EF Core seeding: `context.Database.EnsureCreated()` or explicit `HasData()` in model config
|
||
- Idempotent seeding: `if (!context.Clubs.Any()) { context.Clubs.AddRange(...) }`
|
||
- Deterministic GUIDs: `new Guid("00000000-0000-0000-0000-000000000001")` or `Guid.CreateVersion5(namespace, name)`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Seed data populates database
|
||
Tool: Bash
|
||
Preconditions: Docker Compose up (postgres + keycloak), migrations applied
|
||
Steps:
|
||
1. Run backend in Development mode
|
||
2. Query: `docker compose exec postgres psql -U app_admin -d workclub -c "SELECT count(*) FROM clubs"` → assert 2
|
||
3. Query: `SELECT count(*) FROM members` → assert ≥ 7 (5 users × some in multiple clubs)
|
||
4. Query: `SELECT count(*) FROM work_items` → assert 8
|
||
5. Query: `SELECT count(*) FROM shifts` → assert 5
|
||
Expected Result: All seed data present
|
||
Failure Indicators: Missing data, wrong counts
|
||
Evidence: .sisyphus/evidence/task-11-seed-data.txt
|
||
|
||
Scenario: Seed is idempotent
|
||
Tool: Bash
|
||
Preconditions: Seed already applied
|
||
Steps:
|
||
1. Run backend again (triggers seed again)
|
||
2. Assert same counts as before (no duplicates)
|
||
Expected Result: No duplicate records after re-running seed
|
||
Failure Indicators: Doubled counts
|
||
Evidence: .sisyphus/evidence/task-11-seed-idempotent.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `feat(seed): add development seed data script`
|
||
- Files: `backend/src/WorkClub.Infrastructure/Seed/*.cs`
|
||
- Pre-commit: `dotnet build`
|
||
|
||
- [x] 12. Backend Test Infrastructure (xUnit + Testcontainers + WebApplicationFactory)
|
||
|
||
**What to do**:
|
||
- Configure `WorkClub.Tests.Integration` project:
|
||
- Create `CustomWebApplicationFactory<T>` extending `WebApplicationFactory<Program>`:
|
||
- Override `ConfigureWebHost`: replace PostgreSQL connection with Testcontainers
|
||
- Spin up PostgreSQL container, apply migrations, configure test services
|
||
- Create `TestAuthHandler` for mocking JWT auth in tests:
|
||
- Allows tests to set custom claims (clubs, roles) without real Keycloak
|
||
- Register as default auth scheme in test factory
|
||
- Create base test class `IntegrationTestBase`:
|
||
- Provides `HttpClient` with auth headers
|
||
- Helper methods: `AuthenticateAs(email, clubs)` → sets JWT claims
|
||
- Helper: `SetTenant(tenantId)` → sets X-Tenant-Id header
|
||
- Implements `IAsyncLifetime` for test setup/teardown
|
||
- Create `DatabaseFixture` (collection fixture):
|
||
- Shares single PostgreSQL container across test class
|
||
- Resets data between tests (truncate tables, re-seed)
|
||
- Configure `WorkClub.Tests.Unit` project:
|
||
- Add reference to `WorkClub.Domain` and `WorkClub.Application`
|
||
- No database dependency (pure unit tests)
|
||
- Verify infrastructure works:
|
||
- Write 1 smoke test: GET /health/live → 200
|
||
- Run: `dotnet test backend/tests/WorkClub.Tests.Integration`
|
||
|
||
**Must NOT do**:
|
||
- Do NOT use in-memory database — MUST use real PostgreSQL for RLS testing
|
||
- Do NOT mock EF Core DbContext — use real DbContext with Testcontainers
|
||
- Do NOT share mutable state between test classes
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: Test infrastructure setup with Testcontainers + custom WebApplicationFactory requires careful DI configuration
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 2 (with Tasks 7, 8, 9, 10, 11)
|
||
- **Blocks**: Task 13
|
||
- **Blocked By**: Task 1 (solution structure)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Testcontainers .NET: `new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build()`
|
||
- WebApplicationFactory: `builder.ConfigureServices(services => { /* replace DB */ })`
|
||
- Test auth handler: `services.AddAuthentication("Test").AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", o => {})`
|
||
- `IAsyncLifetime`: `InitializeAsync` / `DisposeAsync` for container lifecycle
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Smoke test passes with Testcontainers
|
||
Tool: Bash
|
||
Preconditions: Docker running (for Testcontainers)
|
||
Steps:
|
||
1. Run `dotnet test backend/tests/WorkClub.Tests.Integration --filter "SmokeTest" --verbosity normal`
|
||
2. Assert exit code 0
|
||
3. Assert output shows 1 test passed
|
||
Expected Result: Health endpoint smoke test passes
|
||
Failure Indicators: Testcontainer fails to start, test timeout
|
||
Evidence: .sisyphus/evidence/task-12-smoke-test.txt
|
||
|
||
Scenario: Test auth handler works
|
||
Tool: Bash
|
||
Preconditions: Test infrastructure configured
|
||
Steps:
|
||
1. Run integration test that:
|
||
a. Creates client with TestAuthHandler claims: {"clubs": {"club-1": "admin"}}
|
||
b. Sends request with X-Tenant-Id: club-1
|
||
c. Asserts controller can read claims correctly
|
||
Expected Result: Custom claims available in controller
|
||
Failure Indicators: Claims missing, auth fails
|
||
Evidence: .sisyphus/evidence/task-12-test-auth.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `test(infra): add xUnit + Testcontainers + WebApplicationFactory base`
|
||
- Files: `backend/tests/WorkClub.Tests.Integration/**/*.cs`, `backend/tests/WorkClub.Tests.Unit/**/*.cs`
|
||
- Pre-commit: `dotnet test backend/tests/WorkClub.Tests.Integration --filter "SmokeTest"`
|
||
|
||
---
|
||
|
||
- [x] 13. RLS Integration Tests — Multi-Tenant Isolation Proof
|
||
|
||
**What to do**:
|
||
- Write comprehensive integration tests proving RLS data isolation:
|
||
- **Test 1: Complete isolation** — Insert data for tenant-1 and tenant-2. Query as tenant-1 → see only tenant-1 data. Query as tenant-2 → see only tenant-2 data. Zero overlap.
|
||
- **Test 2: No context = no data** — Query without `SET LOCAL` → 0 rows returned (RLS blocks everything)
|
||
- **Test 3: Insert protection** — Try to INSERT with tenant-2 context but tenant-1 tenant_id value → RLS blocks
|
||
- **Test 4: Concurrent requests** — Fire 50 parallel HTTP requests alternating tenant-1 and tenant-2 → each response contains ONLY the correct tenant's data (tests connection pool safety)
|
||
- **Test 5: Cross-tenant header spoof** — JWT has clubs: {club-1: admin}, send X-Tenant-Id: club-2 → 403
|
||
- **Test 6: Tenant context in interceptor** — Verify `TenantDbConnectionInterceptor` correctly sets `SET LOCAL` on each request
|
||
- All tests use Testcontainers + WebApplicationFactory from Task 12
|
||
- This is the CRITICAL RISK validation — must pass before building API endpoints
|
||
|
||
**Must NOT do**:
|
||
- Do NOT skip concurrent request test — this validates connection pool safety
|
||
- Do NOT use in-memory database
|
||
- Do NOT mock the RLS layer
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `deep`
|
||
- Reason: Security-critical tests that validate the foundation of multi-tenancy. Concurrent test requires careful async handling.
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 3 (with Tasks 14, 15, 16, 17)
|
||
- **Blocks**: Tasks 14, 15 (unblocks API development once isolation is proven)
|
||
- **Blocked By**: Tasks 7 (schema + RLS), 8 (middleware), 12 (test infra)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 7: `TenantDbConnectionInterceptor` — the interceptor being tested
|
||
- Task 8: `TenantValidationMiddleware` — cross-tenant validation being tested
|
||
- Task 12: `CustomWebApplicationFactory` + `IntegrationTestBase` — test infrastructure
|
||
|
||
**External References**:
|
||
- `Task.WhenAll()` for concurrent request testing
|
||
- `Parallel.ForEachAsync()` for parallel HTTP requests
|
||
- EF Core concurrency: `DbUpdateConcurrencyException` when RowVersion conflicts
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: All 6 RLS isolation tests pass
|
||
Tool: Bash
|
||
Preconditions: Testcontainers + test infrastructure from Task 12
|
||
Steps:
|
||
1. Run `dotnet test backend/tests/WorkClub.Tests.Integration --filter "RlsIsolation" --verbosity normal`
|
||
2. Assert 6 tests pass (complete isolation, no context, insert protection, concurrent, spoof, interceptor)
|
||
Expected Result: All 6 pass, 0 failures
|
||
Failure Indicators: ANY failure means multi-tenancy is BROKEN — stop all downstream work
|
||
Evidence: .sisyphus/evidence/task-13-rls-isolation.txt
|
||
|
||
Scenario: Concurrent request test proves pool safety
|
||
Tool: Bash
|
||
Preconditions: Same as above
|
||
Steps:
|
||
1. Run concurrent test specifically: `dotnet test --filter "ConcurrentRequests" --verbosity detailed`
|
||
2. Review output: 50 requests, each response verified for correct tenant
|
||
3. Assert 0 cross-contamination events
|
||
Expected Result: 50/50 requests return correct tenant data
|
||
Failure Indicators: Any request returns wrong tenant's data
|
||
Evidence: .sisyphus/evidence/task-13-concurrent-safety.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `test(rls): add multi-tenant isolation integration tests`
|
||
- Files: `backend/tests/WorkClub.Tests.Integration/MultiTenancy/*.cs`
|
||
- Pre-commit: `dotnet test backend/tests/WorkClub.Tests.Integration --filter "RlsIsolation"`
|
||
|
||
- [x] 14. Task CRUD API Endpoints + 5-State Workflow
|
||
|
||
**What to do**:
|
||
- Create application services in `WorkClub.Application/Tasks/`:
|
||
- `TaskService`: CRUD operations + state transitions
|
||
- Uses `AppDbContext` directly (no generic repo)
|
||
- Validates state transitions using domain entity's `CanTransitionTo()` method
|
||
- Enforces role permissions: Create/Assign (Admin/Manager), Transition (assignee + Admin/Manager), Delete (Admin)
|
||
- Create minimal API endpoints in `WorkClub.Api/Endpoints/Tasks/`:
|
||
- `GET /api/tasks` — list tasks for current tenant (filtered by RLS). Supports `?status=Open&page=1&pageSize=20`
|
||
- `GET /api/tasks/{id}` — single task detail
|
||
- `POST /api/tasks` — create new task (status: Open). Requires Manager+ role.
|
||
- `PATCH /api/tasks/{id}` — update task (title, description, assignee, status). Enforces state machine.
|
||
- `DELETE /api/tasks/{id}` — soft-delete or hard-delete. Requires Admin role.
|
||
- Handle concurrency: catch `DbUpdateConcurrencyException` → return 409
|
||
- DTOs: `TaskListDto`, `TaskDetailDto`, `CreateTaskRequest`, `UpdateTaskRequest`
|
||
- Write tests FIRST (TDD):
|
||
- Test: CRUD operations work correctly
|
||
- Test: Invalid state transition → 422
|
||
- Test: Concurrency conflict → 409
|
||
- Test: Role enforcement (Viewer can't create)
|
||
|
||
**Must NOT do**:
|
||
- Do NOT use MediatR or CQRS — direct service injection
|
||
- Do NOT create generic CRUD base class
|
||
- Do NOT add full-text search
|
||
- Do NOT add sub-tasks or task dependencies
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `deep`
|
||
- Reason: Core business logic with state machine, concurrency handling, and role-based access
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 3 (with Tasks 13, 15, 16, 17)
|
||
- **Blocks**: Tasks 19, 22, 23
|
||
- **Blocked By**: Tasks 7 (schema), 8 (multi-tenancy), 9 (auth)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 4: `WorkItem` entity with `CanTransitionTo()` / `TransitionTo()` state machine
|
||
- Task 7: `AppDbContext` with `UseXminAsConcurrencyToken()`
|
||
- Task 8: `ITenantProvider.GetTenantId()` for current tenant
|
||
- Task 9: Authorization policies (`RequireManager`, `RequireAdmin`)
|
||
|
||
**External References**:
|
||
- .NET 10 Minimal APIs: `app.MapGet("/api/tasks", handler).RequireAuthorization("RequireMember")`
|
||
- `TypedResults.Ok()`, `TypedResults.NotFound()`, `TypedResults.UnprocessableEntity()`, `TypedResults.Conflict()`
|
||
- Offset pagination: `Skip((page - 1) * pageSize).Take(pageSize)`
|
||
- Concurrency: `catch (DbUpdateConcurrencyException) → TypedResults.Conflict()`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**If TDD:**
|
||
- [ ] Test files in `backend/tests/WorkClub.Tests.Integration/Tasks/`
|
||
- [ ] `dotnet test --filter "Tasks"` → all pass
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Full task lifecycle via API
|
||
Tool: Bash (curl)
|
||
Preconditions: Docker Compose up, seed data loaded, token obtained
|
||
Steps:
|
||
1. POST /api/tasks with admin token + X-Tenant-Id: club-1 → assert 201, status "Open"
|
||
2. PATCH /api/tasks/{id} with status "Assigned" + assigneeId → assert 200, status "Assigned"
|
||
3. PATCH status "InProgress" → assert 200
|
||
4. PATCH status "Review" → assert 200
|
||
5. PATCH status "Done" → assert 200
|
||
6. GET /api/tasks/{id} → assert status "Done"
|
||
Expected Result: Task progresses through all 5 states
|
||
Failure Indicators: Any transition rejected, wrong status
|
||
Evidence: .sisyphus/evidence/task-14-task-lifecycle.txt
|
||
|
||
Scenario: Invalid state transition rejected
|
||
Tool: Bash (curl)
|
||
Preconditions: Task in "Open" status
|
||
Steps:
|
||
1. PATCH /api/tasks/{id} with status "Done" (skipping states) → assert 422
|
||
2. PATCH /api/tasks/{id} with status "InProgress" (skipping Assigned) → assert 422
|
||
Expected Result: Invalid transitions return 422 Unprocessable Entity
|
||
Failure Indicators: Returns 200 (state machine bypassed)
|
||
Evidence: .sisyphus/evidence/task-14-invalid-transition.txt
|
||
|
||
Scenario: Role enforcement on tasks
|
||
Tool: Bash (curl)
|
||
Preconditions: Viewer token obtained
|
||
Steps:
|
||
1. GET /api/tasks with viewer token → assert 200 (can read)
|
||
2. POST /api/tasks with viewer token → assert 403 (cannot create)
|
||
Expected Result: Viewer can read but not create tasks
|
||
Failure Indicators: Viewer can create tasks (privilege escalation)
|
||
Evidence: .sisyphus/evidence/task-14-role-enforcement.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `feat(tasks): add Task CRUD API with 5-state workflow`
|
||
- Files: `backend/src/WorkClub.Api/Endpoints/Tasks/*.cs`, `backend/src/WorkClub.Application/Tasks/*.cs`
|
||
- Pre-commit: `dotnet test backend/tests/ --filter "Tasks"`
|
||
|
||
- [x] 15. Shift CRUD API + Sign-Up/Cancel Endpoints
|
||
|
||
**What to do**:
|
||
- Create application services in `WorkClub.Application/Shifts/`:
|
||
- `ShiftService`: CRUD + sign-up/cancel logic
|
||
- Sign-up: check capacity (count existing sign-ups < shift.Capacity), prevent duplicates, prevent sign-up for past shifts
|
||
- Cancel: remove sign-up record, allow only before shift starts
|
||
- Uses `AppDbContext` directly
|
||
- Handles concurrency for last-slot race: use optimistic concurrency with retry (catch `DbUpdateConcurrencyException`, retry once)
|
||
- Create minimal API endpoints in `WorkClub.Api/Endpoints/Shifts/`:
|
||
- `GET /api/shifts` — list shifts for current tenant. Supports `?from=date&to=date&page=1&pageSize=20`
|
||
- `GET /api/shifts/{id}` — shift detail including sign-up list
|
||
- `POST /api/shifts` — create shift. Requires Manager+ role.
|
||
- `PUT /api/shifts/{id}` — update shift details. Requires Manager+ role.
|
||
- `DELETE /api/shifts/{id}` — delete shift. Requires Admin role.
|
||
- `POST /api/shifts/{id}/signup` — member signs up for shift
|
||
- `DELETE /api/shifts/{id}/signup` — member cancels their sign-up
|
||
- DTOs: `ShiftListDto`, `ShiftDetailDto`, `CreateShiftRequest`, `UpdateShiftRequest`
|
||
- Write tests FIRST (TDD):
|
||
- Test: Sign-up succeeds when capacity available
|
||
- Test: Sign-up rejected when capacity full → 409
|
||
- Test: Sign-up rejected for past shift → 422
|
||
- Test: Cancel succeeds before shift starts
|
||
- Test: Duplicate sign-up rejected → 409
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add recurring shift patterns
|
||
- Do NOT add waitlists or swap requests
|
||
- Do NOT add approval workflow for sign-ups (first-come-first-served)
|
||
- Do NOT add overlapping shift detection
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `deep`
|
||
- Reason: Concurrency-sensitive capacity management with race condition handling
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 3 (with Tasks 13, 14, 16, 17)
|
||
- **Blocks**: Tasks 20, 22
|
||
- **Blocked By**: Tasks 7 (schema), 8 (multi-tenancy), 9 (auth)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 4: `Shift` and `ShiftSignup` entities
|
||
- Task 7: `AppDbContext` with concurrency tokens
|
||
- Task 9: Authorization policies
|
||
|
||
**External References**:
|
||
- Optimistic concurrency retry: `try { SaveChanges } catch (DbUpdateConcurrencyException) { retry once }`
|
||
- Date filtering: `shifts.Where(s => s.StartTime >= from && s.StartTime <= to)`
|
||
- `DateTimeOffset.UtcNow` for past-shift check
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**If TDD:**
|
||
- [ ] Test files in `backend/tests/WorkClub.Tests.Integration/Shifts/`
|
||
- [ ] `dotnet test --filter "Shifts"` → all pass
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Sign-up with capacity enforcement
|
||
Tool: Bash (curl)
|
||
Preconditions: Shift created with capacity=2, 0 sign-ups
|
||
Steps:
|
||
1. POST /api/shifts/{id}/signup with member1 token → assert 200
|
||
2. POST /api/shifts/{id}/signup with member2 token → assert 200
|
||
3. POST /api/shifts/{id}/signup with admin token → assert 409 (capacity full)
|
||
4. GET /api/shifts/{id} → assert signupCount: 2, capacity: 2
|
||
Expected Result: Third sign-up rejected, capacity enforced
|
||
Failure Indicators: Third sign-up succeeds, wrong signup count
|
||
Evidence: .sisyphus/evidence/task-15-capacity.txt
|
||
|
||
Scenario: Past shift sign-up rejected
|
||
Tool: Bash (curl)
|
||
Preconditions: Shift with startTime in the past
|
||
Steps:
|
||
1. POST /api/shifts/{id}/signup → assert 422
|
||
Expected Result: Cannot sign up for past shifts
|
||
Failure Indicators: Sign-up succeeds for past shift
|
||
Evidence: .sisyphus/evidence/task-15-past-shift.txt
|
||
|
||
Scenario: Cancel sign-up before shift
|
||
Tool: Bash (curl)
|
||
Preconditions: Member signed up for future shift
|
||
Steps:
|
||
1. DELETE /api/shifts/{id}/signup with member token → assert 200
|
||
2. GET /api/shifts/{id} → assert signupCount decreased by 1
|
||
Expected Result: Sign-up removed, capacity freed
|
||
Failure Indicators: Sign-up not removed
|
||
Evidence: .sisyphus/evidence/task-15-cancel.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `feat(shifts): add Shift CRUD API with sign-up and capacity`
|
||
- Files: `backend/src/WorkClub.Api/Endpoints/Shifts/*.cs`, `backend/src/WorkClub.Application/Shifts/*.cs`
|
||
- Pre-commit: `dotnet test backend/tests/ --filter "Shifts"`
|
||
|
||
---
|
||
|
||
- [x] 16. Club + Member API Endpoints
|
||
|
||
**What to do**:
|
||
- Create endpoints in `WorkClub.Api/Endpoints/Clubs/`:
|
||
- `GET /api/clubs/me` — list clubs the current user belongs to (from JWT claims + DB)
|
||
- `GET /api/clubs/current` — current club details (from X-Tenant-Id context)
|
||
- `GET /api/members` — list members of current club. Requires Member+ role.
|
||
- `GET /api/members/{id}` — member detail
|
||
- `GET /api/members/me` — current user's membership in current club
|
||
- Create `MemberSyncService`:
|
||
- On first API request from a user, check if their Keycloak `sub` exists in Members table
|
||
- If not, create Member record from JWT claims (name, email, role)
|
||
- This keeps DB in sync with Keycloak without separate sync process
|
||
- Write tests FIRST (TDD):
|
||
- Test: `/api/clubs/me` returns only clubs user belongs to
|
||
- Test: `/api/members` returns only members of current tenant
|
||
- Test: Member auto-sync creates record on first request
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add member management CRUD (invite, remove) — managed in Keycloak
|
||
- Do NOT add club settings or logo upload
|
||
- Do NOT add member search or filtering beyond basic list
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: Member sync logic requires understanding Keycloak-DB relationship
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 3 (with Tasks 13, 14, 15, 17)
|
||
- **Blocks**: Task 18
|
||
- **Blocked By**: Tasks 7 (schema), 8 (multi-tenancy), 9 (auth)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 4: `Member` entity
|
||
- Task 8: `ITenantProvider` for current tenant
|
||
- Task 9: JWT claims parsing for user info
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Club list returns only user's clubs
|
||
Tool: Bash (curl)
|
||
Preconditions: Admin user (member of club-1 + club-2)
|
||
Steps:
|
||
1. GET /api/clubs/me with admin token → assert 200
|
||
2. Assert response contains exactly 2 clubs
|
||
3. GET /api/clubs/me with manager token → assert 1 club (club-1 only)
|
||
Expected Result: Each user sees only their clubs
|
||
Failure Indicators: Wrong club count, sees other users' clubs
|
||
Evidence: .sisyphus/evidence/task-16-clubs-me.txt
|
||
|
||
Scenario: Member auto-sync on first request
|
||
Tool: Bash (curl + psql)
|
||
Preconditions: New user authenticated but no Member record in DB
|
||
Steps:
|
||
1. GET /api/members/me with new user token → assert 200
|
||
2. Query DB: SELECT * FROM members WHERE external_user_id = '{sub}' → assert 1 row
|
||
Expected Result: Member record created automatically
|
||
Failure Indicators: 404 or no DB record
|
||
Evidence: .sisyphus/evidence/task-16-member-sync.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `feat(clubs): add Club and Member API endpoints with auto-sync`
|
||
- Files: `backend/src/WorkClub.Api/Endpoints/Clubs/*.cs`, `backend/src/WorkClub.Application/Members/*.cs`
|
||
- Pre-commit: `dotnet test backend/tests/ --filter "Clubs|Members"`
|
||
|
||
- [x] 17. Frontend Test Infrastructure (Vitest + RTL + Playwright)
|
||
|
||
**What to do**:
|
||
- Install and configure Vitest: `bun add -D vitest @testing-library/react @testing-library/jest-dom @vitejs/plugin-react jsdom`
|
||
- Create `frontend/vitest.config.ts` with React + jsdom environment
|
||
- Install and configure Playwright: `bun add -D @playwright/test && bunx playwright install chromium`
|
||
- Create `frontend/playwright.config.ts` with:
|
||
- Base URL: `http://localhost:3000`
|
||
- Chromium only (faster for development)
|
||
- Screenshot on failure
|
||
- Create test helpers:
|
||
- `frontend/tests/helpers/render.tsx` — custom render with providers (session, tenant context)
|
||
- `frontend/tests/helpers/mock-session.ts` — mock NextAuth session for component tests
|
||
- Write 1 smoke test each:
|
||
- Vitest: render a shadcn Button component, assert it renders
|
||
- Playwright: navigate to homepage, assert page loads
|
||
- Add scripts to `package.json`: `"test": "vitest run"`, `"test:watch": "vitest"`, `"test:e2e": "playwright test"`
|
||
|
||
**Must NOT do**:
|
||
- Do NOT install Jest (use Vitest)
|
||
- Do NOT install Cypress (use Playwright)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Standard test tool setup with config files
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 3 (with Tasks 13, 14, 15, 16)
|
||
- **Blocks**: Tasks 18, 19, 20, 21
|
||
- **Blocked By**: Task 5 (Next.js scaffold)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Vitest + React: `@vitejs/plugin-react` plugin, `jsdom` environment
|
||
- RTL custom render: wrap with providers for consistent test setup
|
||
- Playwright config: `baseURL`, `use.screenshot: 'only-on-failure'`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Vitest smoke test passes
|
||
Tool: Bash
|
||
Preconditions: Vitest configured in frontend/
|
||
Steps:
|
||
1. Run `bun run test` in frontend/
|
||
2. Assert exit code 0, 1 test passed
|
||
Expected Result: Vitest runs and passes smoke test
|
||
Failure Indicators: Config errors, test failure
|
||
Evidence: .sisyphus/evidence/task-17-vitest-smoke.txt
|
||
|
||
Scenario: Playwright smoke test passes
|
||
Tool: Bash
|
||
Preconditions: Playwright + Chromium installed, dev server running
|
||
Steps:
|
||
1. Start dev server in background
|
||
2. Run `bunx playwright test tests/e2e/smoke.spec.ts`
|
||
3. Assert exit code 0
|
||
Expected Result: Playwright navigates to app and page loads
|
||
Failure Indicators: Browser launch failure, navigation timeout
|
||
Evidence: .sisyphus/evidence/task-17-playwright-smoke.txt
|
||
```
|
||
|
||
**Commit**: YES
|
||
- Message: `test(frontend): add Vitest + RTL + Playwright setup`
|
||
- Files: `frontend/vitest.config.ts`, `frontend/playwright.config.ts`, `frontend/tests/**`
|
||
- Pre-commit: `bun run test` (in frontend/)
|
||
|
||
---
|
||
|
||
- [x] 18. App Layout + Club-Switcher + Auth Guard
|
||
|
||
**What to do**:
|
||
- Create root layout (`frontend/src/app/layout.tsx`):
|
||
- SessionProvider wrapper (NextAuth)
|
||
- TenantProvider wrapper (active club context)
|
||
- Sidebar navigation: Dashboard, Tasks, Shifts, Members
|
||
- Top bar: Club-switcher dropdown, user avatar/name, logout button
|
||
- Create `ClubSwitcher` component (`frontend/src/components/club-switcher.tsx`):
|
||
- Dropdown (shadcn DropdownMenu) showing user's clubs
|
||
- On switch: update local storage + cookie, refetch all data (TanStack Query `queryClient.invalidateQueries()`)
|
||
- Show current club name + sport type badge
|
||
- Create `AuthGuard` component:
|
||
- Wraps protected pages
|
||
- If not authenticated → redirect to /login
|
||
- If authenticated, no active club, 1 club → auto-select
|
||
- If authenticated, no active club, multiple clubs → redirect to /select-club
|
||
- If authenticated, 0 clubs → show "Contact admin" message
|
||
- Install TanStack Query: `bun add @tanstack/react-query`
|
||
- Create `QueryProvider` wrapper with `QueryClient`
|
||
- Create `TenantContext` (React Context) with `activeClubId`, `setActiveClub()`, `userRole`
|
||
- Write tests FIRST (TDD):
|
||
- Test: ClubSwitcher renders clubs from session
|
||
- Test: ClubSwitcher calls setActiveClub on selection
|
||
- Test: AuthGuard redirects when no session
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add settings pages
|
||
- Do NOT add theme customization per club
|
||
- Do NOT create custom component wrappers over shadcn/ui
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `visual-engineering`
|
||
- Reason: Layout design, responsive sidebar, dropdown component — frontend UI work
|
||
- **Skills**: [`frontend-ui-ux`]
|
||
- `frontend-ui-ux`: Crafts clean UI layout even without mockups
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 4 (with Tasks 19, 20, 21)
|
||
- **Blocks**: Tasks 19, 20, 22
|
||
- **Blocked By**: Tasks 10 (NextAuth), 16 (Club API), 17 (test infra)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 10: NextAuth session with clubs claim
|
||
- Task 16: `GET /api/clubs/me` endpoint for club list
|
||
|
||
**External References**:
|
||
- shadcn/ui DropdownMenu: `<DropdownMenu><DropdownMenuTrigger>` pattern
|
||
- TanStack Query: `useQuery({ queryKey: ['clubs'], queryFn: fetchClubs })`
|
||
- React Context: `createContext<TenantContextType>()`
|
||
- Next.js App Router layout: shared across all pages in directory
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Club switcher renders and switches
|
||
Tool: Bash (bun test — Vitest + RTL)
|
||
Preconditions: Mock session with 2 clubs
|
||
Steps:
|
||
1. Render ClubSwitcher with mock session containing clubs: [{name: "Tennis"}, {name: "Cycling"}]
|
||
2. Assert both club names visible in dropdown
|
||
3. Click "Cycling" → assert setActiveClub called with cycling club ID
|
||
Expected Result: Switcher renders clubs and handles selection
|
||
Failure Indicators: Clubs not rendered, click handler not called
|
||
Evidence: .sisyphus/evidence/task-18-club-switcher.txt
|
||
|
||
Scenario: Layout renders with navigation
|
||
Tool: Playwright
|
||
Preconditions: Frontend running with mock auth
|
||
Steps:
|
||
1. Navigate to /dashboard
|
||
2. Assert sidebar contains links: "Tasks", "Shifts", "Members"
|
||
3. Assert top bar shows club name and user name
|
||
4. Take screenshot
|
||
Expected Result: Full layout visible with navigation
|
||
Failure Indicators: Missing sidebar, broken layout
|
||
Evidence: .sisyphus/evidence/task-18-layout.png
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 19, 20, 21)
|
||
- Message: `feat(ui): add layout, club-switcher, login, task and shift pages`
|
||
- Files: `frontend/src/app/layout.tsx`, `frontend/src/components/club-switcher.tsx`, `frontend/src/components/auth-guard.tsx`
|
||
- Pre-commit: `bun run build && bun run test` (in frontend/)
|
||
|
||
- [x] 19. Task List + Task Detail + Status Transitions UI
|
||
|
||
**What to do**:
|
||
- Create `/frontend/src/app/(protected)/tasks/page.tsx`:
|
||
- Task list view using shadcn Table component
|
||
- Columns: Title, Status (Badge with color per status), Assignee, Due Date, Actions
|
||
- Filter by status (DropdownMenu with status options)
|
||
- Pagination (offset-based)
|
||
- "New Task" button (visible for Manager+ role)
|
||
- Create `/frontend/src/app/(protected)/tasks/[id]/page.tsx`:
|
||
- Task detail view
|
||
- Status transition buttons (only valid next states shown)
|
||
- Assign member dropdown (for Manager+ role)
|
||
- Edit title/description (for Manager+ or assignee)
|
||
- Create `/frontend/src/app/(protected)/tasks/new/page.tsx`:
|
||
- New task form: title, description, due date (optional)
|
||
- Form validation with shadcn form components
|
||
- Use TanStack Query hooks:
|
||
- `useTasks(filters)`, `useTask(id)`, `useCreateTask()`, `useUpdateTask()`, `useTransitionTask()`
|
||
- Write tests FIRST (TDD):
|
||
- Test: Task list renders with mock data
|
||
- Test: Status badge shows correct color per status
|
||
- Test: Only valid transition buttons are shown
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add drag-and-drop Kanban board
|
||
- Do NOT add inline editing
|
||
- Do NOT add bulk actions
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `visual-engineering`
|
||
- Reason: Data table, form design, interactive status transitions
|
||
- **Skills**: [`frontend-ui-ux`]
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 4 (with Tasks 18, 20, 21)
|
||
- **Blocks**: Task 27
|
||
- **Blocked By**: Tasks 14 (Task API), 17 (test infra), 18 (layout)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 14: Task API endpoints (GET /api/tasks, PATCH /api/tasks/{id})
|
||
- Task 18: Layout with TenantContext for API headers
|
||
- Task 4: WorkItemStatus enum values and valid transitions
|
||
|
||
**External References**:
|
||
- shadcn Table: `<Table><TableHeader><TableBody>` with mapped rows
|
||
- shadcn Badge: `<Badge variant="outline">Open</Badge>` with color variants
|
||
- TanStack Query mutations: `useMutation({ mutationFn: updateTask, onSuccess: invalidate })`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Task list renders with data
|
||
Tool: Bash (bun test)
|
||
Preconditions: Mock API response with 5 tasks
|
||
Steps:
|
||
1. Render TaskListPage with mocked TanStack Query
|
||
2. Assert 5 table rows rendered
|
||
3. Assert each row has title, status badge, assignee
|
||
Expected Result: All tasks displayed in table
|
||
Failure Indicators: Missing rows, wrong data
|
||
Evidence: .sisyphus/evidence/task-19-task-list.txt
|
||
|
||
Scenario: Status transition buttons shown correctly
|
||
Tool: Bash (bun test)
|
||
Preconditions: Task with status "InProgress"
|
||
Steps:
|
||
1. Render TaskDetailPage with task in "InProgress" status
|
||
2. Assert "Move to Review" button is visible
|
||
3. Assert "Mark as Done" button is NOT visible (invalid transition)
|
||
Expected Result: Only valid transitions shown
|
||
Failure Indicators: Invalid transitions displayed
|
||
Evidence: .sisyphus/evidence/task-19-transitions.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 18, 20, 21)
|
||
- Message: (grouped in Task 18 commit)
|
||
- Files: `frontend/src/app/(protected)/tasks/**/*.tsx`, `frontend/src/hooks/useTasks.ts`
|
||
- Pre-commit: `bun run build && bun run test`
|
||
|
||
- [x] 20. Shift List + Shift Detail + Sign-Up UI
|
||
|
||
**What to do**:
|
||
- Create `/frontend/src/app/(protected)/shifts/page.tsx`:
|
||
- Shift list view using shadcn Card components (not table — more visual for schedules)
|
||
- Each card: Title, Date/Time, Location, Capacity bar (X/Y signed up), Sign-up button
|
||
- Filter by date range (DatePicker)
|
||
- "New Shift" button (visible for Manager+ role)
|
||
- Create `/frontend/src/app/(protected)/shifts/[id]/page.tsx`:
|
||
- Shift detail: full info + list of signed-up members
|
||
- "Sign Up" button (if capacity available and shift is future)
|
||
- "Cancel Sign-up" button (if user is signed up)
|
||
- Visual capacity indicator (progress bar)
|
||
- Create `/frontend/src/app/(protected)/shifts/new/page.tsx`:
|
||
- New shift form: title, description, location, start time, end time, capacity
|
||
- TanStack Query hooks: `useShifts(dateRange)`, `useShift(id)`, `useSignUp()`, `useCancelSignUp()`
|
||
- Write tests FIRST (TDD):
|
||
- Test: Shift card shows capacity correctly (2/3)
|
||
- Test: Sign-up button disabled when full
|
||
- Test: Past shift shows "Past" label, no sign-up button
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add calendar view (list/card view only for MVP)
|
||
- Do NOT add recurring shift creation
|
||
- Do NOT add shift swap functionality
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `visual-engineering`
|
||
- Reason: Card-based UI, capacity visualization, date/time components
|
||
- **Skills**: [`frontend-ui-ux`]
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 4 (with Tasks 18, 19, 21)
|
||
- **Blocks**: Task 28
|
||
- **Blocked By**: Tasks 15 (Shift API), 17 (test infra), 18 (layout)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 15: Shift API endpoints
|
||
- Task 18: Layout with TenantContext
|
||
- Task 4: Shift entity fields
|
||
|
||
**External References**:
|
||
- shadcn Card: `<Card><CardHeader><CardContent>` for shift cards
|
||
- Progress component: shadcn Progress for capacity bar
|
||
- Date picker: shadcn Calendar + Popover for date range filter
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Shift card shows capacity and sign-up state
|
||
Tool: Bash (bun test)
|
||
Preconditions: Mock shift with capacity 3, 2 signed up
|
||
Steps:
|
||
1. Render ShiftCard with mock data
|
||
2. Assert "2/3 spots filled" text visible
|
||
3. Assert "Sign Up" button is enabled (1 spot left)
|
||
4. Re-render with capacity 3, 3 signed up
|
||
5. Assert "Sign Up" button is disabled
|
||
Expected Result: Capacity displayed correctly, button state matches availability
|
||
Failure Indicators: Wrong count, button enabled when full
|
||
Evidence: .sisyphus/evidence/task-20-capacity-display.txt
|
||
|
||
Scenario: Past shift cannot be signed up for
|
||
Tool: Bash (bun test)
|
||
Preconditions: Shift with startTime in the past
|
||
Steps:
|
||
1. Render ShiftCard with past shift
|
||
2. Assert "Past" label visible
|
||
3. Assert "Sign Up" button not rendered
|
||
Expected Result: Past shifts clearly marked, no sign-up option
|
||
Failure Indicators: Sign-up button on past shift
|
||
Evidence: .sisyphus/evidence/task-20-past-shift.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 18, 19, 21)
|
||
- Message: (grouped in Task 18 commit)
|
||
- Files: `frontend/src/app/(protected)/shifts/**/*.tsx`, `frontend/src/hooks/useShifts.ts`
|
||
- Pre-commit: `bun run build && bun run test`
|
||
|
||
- [x] 21. Login Page + First-Login Club Picker
|
||
|
||
**What to do**:
|
||
- Create `/frontend/src/app/login/page.tsx`:
|
||
- Clean login page with "Sign in with Keycloak" button
|
||
- Uses NextAuth `signIn("keycloak")` function
|
||
- Shows app name/logo placeholder
|
||
- Create `/frontend/src/app/select-club/page.tsx`:
|
||
- Club selection page for multi-club users
|
||
- Shows cards for each club with name and sport type
|
||
- Clicking a club → sets active club → redirects to /dashboard
|
||
- Only shown when user has 2+ clubs and no active club
|
||
- Create `/frontend/src/app/(protected)/dashboard/page.tsx`:
|
||
- Simple dashboard showing:
|
||
- Active club name
|
||
- My open tasks count
|
||
- My upcoming shifts count
|
||
- Quick links to Tasks and Shifts pages
|
||
- Write tests FIRST (TDD):
|
||
- Test: Login page renders sign-in button
|
||
- Test: Club picker shows correct number of clubs
|
||
- Test: Dashboard shows summary counts
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add custom login form (use Keycloak hosted login)
|
||
- Do NOT add registration page
|
||
- Do NOT add charts or analytics on dashboard
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `visual-engineering`
|
||
- Reason: Login page design, club selection cards, dashboard layout
|
||
- **Skills**: [`frontend-ui-ux`]
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 4 (with Tasks 18, 19, 20)
|
||
- **Blocks**: Task 26
|
||
- **Blocked By**: Tasks 10 (NextAuth), 17 (test infra)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 10: `signIn("keycloak")` and session with clubs claim
|
||
- Task 18: AuthGuard redirects to /select-club or /login
|
||
|
||
**External References**:
|
||
- NextAuth signIn: `import { signIn } from "next-auth/react"` → `signIn("keycloak")`
|
||
- shadcn Card: for club selection cards
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Login page renders
|
||
Tool: Bash (bun test)
|
||
Steps:
|
||
1. Render LoginPage
|
||
2. Assert "Sign in" button visible
|
||
Expected Result: Login page renders with sign-in button
|
||
Failure Indicators: Missing button, render error
|
||
Evidence: .sisyphus/evidence/task-21-login-page.txt
|
||
|
||
Scenario: Club picker for multi-club user
|
||
Tool: Bash (bun test)
|
||
Preconditions: Mock session with 2 clubs, no active club
|
||
Steps:
|
||
1. Render SelectClubPage with mock session
|
||
2. Assert 2 club cards rendered
|
||
3. Click first card → assert redirect to /dashboard
|
||
Expected Result: Club picker shows clubs and handles selection
|
||
Failure Indicators: Wrong club count, no redirect
|
||
Evidence: .sisyphus/evidence/task-21-club-picker.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 18, 19, 20)
|
||
- Message: (grouped in Task 18 commit)
|
||
- Files: `frontend/src/app/login/page.tsx`, `frontend/src/app/select-club/page.tsx`, `frontend/src/app/(protected)/dashboard/page.tsx`
|
||
- Pre-commit: `bun run build && bun run test`
|
||
|
||
---
|
||
|
||
- [x] 22. Docker Compose Full Stack (Backend + Frontend + Hot Reload)
|
||
|
||
**What to do**:
|
||
- Update `/docker-compose.yml` to add:
|
||
- `dotnet-api` service: build from `backend/Dockerfile.dev`, port 5000→8080, volume mount `/backend:/app` (hot reload via `dotnet watch`), depends on postgres + keycloak
|
||
- `nextjs` service: build from `frontend/Dockerfile.dev`, port 3000, volume mount `/frontend:/app` (hot reload via `bun run dev`), depends on dotnet-api
|
||
- Configure environment variables:
|
||
- Backend: `ConnectionStrings__DefaultConnection`, `Keycloak__Issuer`, `ASPNETCORE_ENVIRONMENT=Development`
|
||
- Frontend: `NEXT_PUBLIC_API_URL=http://localhost:5000`, `API_INTERNAL_URL=http://dotnet-api:8080`, `KEYCLOAK_ISSUER`
|
||
- Ensure service startup order: postgres → keycloak → dotnet-api → nextjs
|
||
- Add `wait-for-it` or health-check-based depends_on conditions
|
||
- Backend runs migrations + seed on startup in Development mode
|
||
- Verify: `docker compose up` → all 4 services healthy → frontend accessible at localhost:3000 → can authenticate via Keycloak
|
||
|
||
**Must NOT do**:
|
||
- Do NOT use production Dockerfiles for local dev (use Dockerfile.dev with hot reload)
|
||
- Do NOT hardcode production secrets
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: Multi-service Docker Compose orchestration with health checks and dependency ordering
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 5 (with Tasks 23, 24, 25)
|
||
- **Blocks**: Tasks 26, 27, 28
|
||
- **Blocked By**: Tasks 14 (API), 15 (Shifts API), 18 (Frontend layout)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 2: Docker Compose base (postgres + keycloak)
|
||
- Task 11: Seed data service (runs on Development startup)
|
||
|
||
**External References**:
|
||
- `dotnet watch run`: Hot reload in Docker with volume mount
|
||
- Docker Compose `depends_on` with `condition: service_healthy`
|
||
- Volume mount caching: `:cached` on macOS for performance
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Full stack starts from clean state
|
||
Tool: Bash
|
||
Preconditions: Docker installed, no conflicting ports
|
||
Steps:
|
||
1. Run `docker compose down -v` (clean slate)
|
||
2. Run `docker compose up -d`
|
||
3. Wait up to 180s for all services healthy: `docker compose ps`
|
||
4. Assert 4 services running: postgres, keycloak, dotnet-api, nextjs
|
||
5. curl http://localhost:5000/health/live → assert 200
|
||
6. curl http://localhost:3000 → assert 200
|
||
7. curl Keycloak OIDC discovery → assert 200
|
||
Expected Result: All 4 services healthy and responding
|
||
Failure Indicators: Service fails to start, health check fails
|
||
Evidence: .sisyphus/evidence/task-22-full-stack.txt
|
||
|
||
Scenario: Authentication works end-to-end
|
||
Tool: Bash (curl)
|
||
Preconditions: Full stack running
|
||
Steps:
|
||
1. Get token from Keycloak for admin@test.com
|
||
2. Call GET /api/tasks with token + X-Tenant-Id → assert 200
|
||
3. Assert response contains seed data tasks
|
||
Expected Result: Auth + API + data all connected
|
||
Failure Indicators: Auth fails, API returns 401/403, no data
|
||
Evidence: .sisyphus/evidence/task-22-e2e-auth.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 23, 24, 25)
|
||
- Message: `infra(deploy): add full Docker Compose stack, Dockerfiles, and Kustomize dev overlay`
|
||
- Files: `docker-compose.yml`
|
||
- Pre-commit: `docker compose config`
|
||
|
||
- [x] 23. Backend Dockerfiles (Dev + Prod Multi-Stage)
|
||
|
||
**What to do**:
|
||
- Create `/backend/Dockerfile.dev`:
|
||
- Base: `mcr.microsoft.com/dotnet/sdk:10.0`
|
||
- Install dotnet-ef tool
|
||
- WORKDIR /app, copy csproj + restore, copy source
|
||
- ENTRYPOINT: `dotnet watch run --project src/WorkClub.Api/WorkClub.Api.csproj`
|
||
- Create `/backend/Dockerfile`:
|
||
- Multi-stage build:
|
||
- Stage 1 (build): `sdk:10.0`, restore + build + publish
|
||
- Stage 2 (runtime): `aspnet:10.0-alpine`, copy published output, non-root user
|
||
- HEALTHCHECK: `curl -sf http://localhost:8080/health/live || exit 1`
|
||
- Final image ~110MB
|
||
|
||
**Must NOT do**:
|
||
- Do NOT use full SDK image for production
|
||
- Do NOT run as root in production image
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Standard .NET Docker patterns
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 5 (with Tasks 22, 24, 25)
|
||
- **Blocks**: Task 25
|
||
- **Blocked By**: Task 14 (working API to build)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- .NET 10 Docker images: `mcr.microsoft.com/dotnet/aspnet:10.0-alpine` (runtime), `sdk:10.0` (build)
|
||
- Multi-stage: restore in separate layer for caching
|
||
- Non-root: `USER app` (built into .NET Docker images)
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Production image builds and runs
|
||
Tool: Bash
|
||
Steps:
|
||
1. Run `docker build -t workclub-api:test backend/`
|
||
2. Assert build succeeds
|
||
3. Run `docker run --rm -d -p 18080:8080 --name test-api workclub-api:test`
|
||
4. Wait 10s, curl http://localhost:18080/health/live
|
||
5. Assert response (may fail without DB — that's OK, just verify container starts)
|
||
6. Check image size: `docker image inspect workclub-api:test --format='{{.Size}}'`
|
||
7. Assert < 200MB
|
||
8. Cleanup: `docker stop test-api`
|
||
Expected Result: Image builds, starts, is <200MB
|
||
Failure Indicators: Build fails, image too large
|
||
Evidence: .sisyphus/evidence/task-23-backend-docker.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 22, 24, 25)
|
||
- Message: (grouped in Task 22 commit)
|
||
- Files: `backend/Dockerfile`, `backend/Dockerfile.dev`
|
||
- Pre-commit: `docker build -t test backend/`
|
||
|
||
- [x] 24. Frontend Dockerfiles (Dev + Prod Standalone)
|
||
|
||
**What to do**:
|
||
- Create `/frontend/Dockerfile.dev`:
|
||
- Base: `node:22-alpine`
|
||
- Install bun: `npm install -g bun`
|
||
- Copy package.json + bun.lock, install deps
|
||
- CMD: `bun run dev`
|
||
- Create `/frontend/Dockerfile`:
|
||
- Multi-stage build (3 stages):
|
||
- Stage 1 (deps): `node:22-alpine`, install bun, `bun install --frozen-lockfile`
|
||
- Stage 2 (build): copy deps + source, `bun run build`
|
||
- Stage 3 (runner): `node:22-alpine`, copy `.next/standalone` + `.next/static` + `public`, non-root user
|
||
- CMD: `node server.js`
|
||
- HEALTHCHECK: `curl -sf http://localhost:3000/api/health || exit 1`
|
||
- Final image ~180MB
|
||
|
||
**Must NOT do**:
|
||
- Do NOT use Bun as production runtime (latency issues) — use Node.js
|
||
- Do NOT include dev dependencies in production image
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `quick`
|
||
- Reason: Standard Next.js Docker patterns
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 5 (with Tasks 22, 23, 25)
|
||
- **Blocks**: Task 25
|
||
- **Blocked By**: Task 18 (working frontend)
|
||
|
||
**References**:
|
||
|
||
**External References**:
|
||
- Next.js standalone Docker: copy `.next/standalone`, `.next/static`, `public`
|
||
- `node server.js` as entry point (standalone output)
|
||
- Non-root user: `adduser --system --uid 1001 nextjs`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Production image builds and starts
|
||
Tool: Bash
|
||
Steps:
|
||
1. Run `docker build -t workclub-frontend:test frontend/`
|
||
2. Assert build succeeds
|
||
3. Run `docker run --rm -d -p 13000:3000 --name test-frontend workclub-frontend:test`
|
||
4. Wait 10s, curl http://localhost:13000
|
||
5. Assert HTTP 200
|
||
6. Check image size < 250MB
|
||
7. Cleanup: `docker stop test-frontend`
|
||
Expected Result: Frontend image builds, starts, serves pages
|
||
Failure Indicators: Build fails, no response
|
||
Evidence: .sisyphus/evidence/task-24-frontend-docker.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 22, 23, 25)
|
||
- Message: (grouped in Task 22 commit)
|
||
- Files: `frontend/Dockerfile`, `frontend/Dockerfile.dev`
|
||
- Pre-commit: `docker build -t test frontend/`
|
||
|
||
- [x] 25. Kustomize Dev Overlay + Resource Limits + Health Checks
|
||
|
||
**What to do**:
|
||
- Create `/infra/k8s/overlays/dev/kustomization.yaml`:
|
||
- Reference `../../base`
|
||
- Override replicas: 1 for all deployments
|
||
- Override resource limits: lower CPU/memory for dev
|
||
- Add dev-specific ConfigMap values (Development env, debug logging)
|
||
- Add dev-specific image tags
|
||
- Create `/infra/k8s/overlays/dev/patches/`:
|
||
- `backend-resources.yaml`: Lower resource requests/limits for dev
|
||
- `frontend-resources.yaml`: Lower resources
|
||
- Update base manifests if needed:
|
||
- Ensure health check endpoints match actual implementations
|
||
- Backend: `/health/startup`, `/health/live`, `/health/ready` (from Task 9)
|
||
- Frontend: `/api/health` (from Task 5 or Task 18)
|
||
- Verify: `kustomize build infra/k8s/overlays/dev` produces valid YAML with dev overrides
|
||
|
||
**Must NOT do**:
|
||
- Do NOT add HPA or PDB (not needed for dev)
|
||
- Do NOT add production secrets
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: Kustomize overlay configuration with patches
|
||
- **Skills**: []
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 5 (with Tasks 22, 23, 24)
|
||
- **Blocks**: None
|
||
- **Blocked By**: Tasks 6 (base manifests), 23 (backend Dockerfile), 24 (frontend Dockerfile)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 6: Kustomize base manifests
|
||
- Task 9: Health check endpoints
|
||
|
||
**External References**:
|
||
- Kustomize overlays: `resources: [../../base]` + `patches:` list
|
||
- `replicas:` directive for scaling
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Dev overlay builds with correct overrides
|
||
Tool: Bash
|
||
Steps:
|
||
1. Run `kustomize build infra/k8s/overlays/dev`
|
||
2. Assert exit code 0
|
||
3. Verify replicas are 1 for all deployments
|
||
4. Verify resource limits are lower than base
|
||
Expected Result: Valid YAML with dev-specific overrides
|
||
Failure Indicators: Build fails, wrong values
|
||
Evidence: .sisyphus/evidence/task-25-kustomize-dev.txt
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 22, 23, 24)
|
||
- Message: (grouped in Task 22 commit)
|
||
- Files: `infra/k8s/overlays/dev/**/*.yaml`
|
||
- Pre-commit: `kustomize build infra/k8s/overlays/dev`
|
||
|
||
---
|
||
|
||
- [ ] 26. Playwright E2E Tests — Auth Flow + Club Switching
|
||
|
||
**What to do**:
|
||
- Create `frontend/tests/e2e/auth.spec.ts`:
|
||
- Test: Navigate to protected page → redirected to login
|
||
- Test: Click "Sign in" → redirected to Keycloak login → enter credentials → redirected back with session
|
||
- Test: After login, club picker shown (if multi-club user)
|
||
- Test: Select club → redirected to dashboard → club name visible in header
|
||
- Test: Switch club via dropdown → data refreshes → new club name visible
|
||
- Test: Logout → redirected to login page → protected page no longer accessible
|
||
- Configure Playwright to work with Docker Compose stack:
|
||
- `webServer` config points to Docker Compose `nextjs` service
|
||
- Use `globalSetup` to ensure Docker Compose is running
|
||
- Save screenshots and trace on failure
|
||
|
||
**Must NOT do**:
|
||
- Do NOT test Keycloak admin console
|
||
- Do NOT test direct API calls (covered in backend tests)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: E2E browser automation with Keycloak OIDC redirect flow
|
||
- **Skills**: [`playwright`]
|
||
- `playwright`: Browser automation for E2E testing
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 6 (with Tasks 27, 28)
|
||
- **Blocks**: None
|
||
- **Blocked By**: Tasks 21 (login page), 22 (Docker Compose full stack)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 21: Login page with Keycloak sign-in
|
||
- Task 18: Club-switcher component
|
||
- Task 3: Test user credentials (admin@test.com / testpass123)
|
||
|
||
**External References**:
|
||
- Playwright Keycloak login: `page.goto('/dashboard')` → assert redirect to Keycloak → `page.fill('#username')` → `page.fill('#password')` → `page.click('#kc-login')`
|
||
- Playwright screenshot: `await page.screenshot({ path: 'evidence/...' })`
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Full auth flow E2E
|
||
Tool: Playwright
|
||
Preconditions: Docker Compose full stack running
|
||
Steps:
|
||
1. Navigate to http://localhost:3000/dashboard
|
||
2. Assert redirected to /login page
|
||
3. Click "Sign in" button
|
||
4. Assert redirected to Keycloak login (http://localhost:8080/realms/workclub/...)
|
||
5. Fill username: "admin@test.com", password: "testpass123"
|
||
6. Click Login
|
||
7. Assert redirected back to /select-club (multi-club user)
|
||
8. Click first club card ("Sunrise Tennis Club")
|
||
9. Assert URL is /dashboard
|
||
10. Assert text "Sunrise Tennis Club" visible in header
|
||
11. Screenshot
|
||
Expected Result: Complete login + club selection flow works
|
||
Failure Indicators: Redirect loop, Keycloak errors, stuck on club picker
|
||
Evidence: .sisyphus/evidence/task-26-auth-flow.png
|
||
|
||
Scenario: Club switching refreshes data
|
||
Tool: Playwright
|
||
Preconditions: Logged in, club-1 active
|
||
Steps:
|
||
1. Navigate to /tasks → assert tasks visible (club-1 data)
|
||
2. Open club switcher dropdown
|
||
3. Click "Valley Cycling Club"
|
||
4. Assert tasks list updates (different data)
|
||
5. Assert header shows "Valley Cycling Club"
|
||
Expected Result: Switching clubs changes visible data
|
||
Failure Indicators: Data doesn't refresh, old club's data shown
|
||
Evidence: .sisyphus/evidence/task-26-club-switch.png
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 27, 28)
|
||
- Message: `test(e2e): add Playwright E2E tests for auth, tasks, and shifts`
|
||
- Files: `frontend/tests/e2e/auth.spec.ts`
|
||
- Pre-commit: `bunx playwright test tests/e2e/auth.spec.ts`
|
||
|
||
- [ ] 27. Playwright E2E Tests — Task Management Flow
|
||
|
||
**What to do**:
|
||
- Create `frontend/tests/e2e/tasks.spec.ts`:
|
||
- Test: Create new task → appears in list
|
||
- Test: View task detail → all fields displayed
|
||
- Test: Transition task through all states (Open → Assigned → InProgress → Review → Done)
|
||
- Test: Viewer role cannot see "New Task" button
|
||
- Test: Task list filters by status
|
||
- Use authenticated session from Task 26 setup
|
||
|
||
**Must NOT do**:
|
||
- Do NOT test API directly (use UI interactions only)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: Complex E2E flow with multiple pages and state transitions
|
||
- **Skills**: [`playwright`]
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 6 (with Tasks 26, 28)
|
||
- **Blocks**: None
|
||
- **Blocked By**: Tasks 19 (task UI), 22 (Docker Compose)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 19: Task list page, task detail page, task creation form
|
||
- Task 14: Task API behavior (valid/invalid transitions)
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Full task lifecycle via UI
|
||
Tool: Playwright
|
||
Preconditions: Logged in as admin, club-1 active
|
||
Steps:
|
||
1. Navigate to /tasks
|
||
2. Click "New Task" button
|
||
3. Fill title: "Replace court net", description: "Net on court 3 is torn"
|
||
4. Click Submit → assert redirected to task detail
|
||
5. Assert status badge shows "Open"
|
||
6. Click "Assign" → select member → assert status "Assigned"
|
||
7. Click "Start" → assert status "In Progress"
|
||
8. Click "Submit for Review" → assert status "Review"
|
||
9. Click "Approve" → assert status "Done"
|
||
10. Screenshot final state
|
||
Expected Result: Task flows through all 5 states via UI
|
||
Failure Indicators: Transition fails, wrong status shown
|
||
Evidence: .sisyphus/evidence/task-27-task-lifecycle.png
|
||
|
||
Scenario: Viewer cannot create tasks
|
||
Tool: Playwright
|
||
Preconditions: Logged in as viewer@test.com
|
||
Steps:
|
||
1. Navigate to /tasks
|
||
2. Assert "New Task" button is NOT visible
|
||
Expected Result: Viewer sees task list but no create button
|
||
Failure Indicators: Create button visible for viewer
|
||
Evidence: .sisyphus/evidence/task-27-viewer-no-create.png
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 26, 28)
|
||
- Message: (grouped in Task 26 commit)
|
||
- Files: `frontend/tests/e2e/tasks.spec.ts`
|
||
- Pre-commit: `bunx playwright test tests/e2e/tasks.spec.ts`
|
||
|
||
- [ ] 28. Playwright E2E Tests — Shift Sign-Up Flow
|
||
|
||
**What to do**:
|
||
- Create `frontend/tests/e2e/shifts.spec.ts`:
|
||
- Test: Create new shift → appears in list
|
||
- Test: View shift detail → capacity bar shows 0/N
|
||
- Test: Sign up for shift → capacity updates, user listed
|
||
- Test: Cancel sign-up → capacity decreases
|
||
- Test: Full capacity → sign-up button disabled
|
||
- Test: Past shift → no sign-up button
|
||
- Use authenticated session from Task 26 setup
|
||
|
||
**Must NOT do**:
|
||
- Do NOT test concurrent sign-ups via UI (covered in backend integration tests)
|
||
|
||
**Recommended Agent Profile**:
|
||
- **Category**: `unspecified-high`
|
||
- Reason: E2E flow with capacity state tracking
|
||
- **Skills**: [`playwright`]
|
||
|
||
**Parallelization**:
|
||
- **Can Run In Parallel**: YES
|
||
- **Parallel Group**: Wave 6 (with Tasks 26, 27)
|
||
- **Blocks**: None
|
||
- **Blocked By**: Tasks 20 (shift UI), 22 (Docker Compose)
|
||
|
||
**References**:
|
||
|
||
**Pattern References**:
|
||
- Task 20: Shift list page, shift detail page, sign-up button
|
||
- Task 15: Shift API behavior (capacity, past shift rejection)
|
||
|
||
**Acceptance Criteria**:
|
||
|
||
**QA Scenarios (MANDATORY):**
|
||
|
||
```
|
||
Scenario: Sign up and cancel for shift
|
||
Tool: Playwright
|
||
Preconditions: Logged in as member1, future shift with capacity 3, 0 signed up
|
||
Steps:
|
||
1. Navigate to /shifts
|
||
2. Click on future shift card
|
||
3. Assert "0/3 spots filled"
|
||
4. Click "Sign Up" → assert "1/3 spots filled"
|
||
5. Assert user name appears in sign-up list
|
||
6. Click "Cancel Sign-up" → assert "0/3 spots filled"
|
||
7. Screenshot
|
||
Expected Result: Sign-up and cancel update capacity correctly
|
||
Failure Indicators: Count doesn't update, name not shown
|
||
Evidence: .sisyphus/evidence/task-28-shift-signup.png
|
||
|
||
Scenario: Full capacity disables sign-up
|
||
Tool: Playwright
|
||
Preconditions: Shift with capacity 1, 1 already signed up
|
||
Steps:
|
||
1. Navigate to shift detail for full shift
|
||
2. Assert "1/1 spots filled"
|
||
3. Assert "Sign Up" button is disabled or not present
|
||
Expected Result: Cannot sign up for full shift
|
||
Failure Indicators: Sign-up button still active
|
||
Evidence: .sisyphus/evidence/task-28-full-capacity.png
|
||
```
|
||
|
||
**Commit**: YES (groups with Tasks 26, 27)
|
||
- Message: (grouped in Task 26 commit)
|
||
- Files: `frontend/tests/e2e/shifts.spec.ts`
|
||
- Pre-commit: `bunx playwright test tests/e2e/shifts.spec.ts`
|
||
|
||
---
|
||
|
||
## Final Verification Wave
|
||
|
||
> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
|
||
|
||
- [ ] F1. **Plan Compliance Audit** — `oracle`
|
||
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns (`MediatR`, `IRepository<T>`, `Swashbuckle`, `IsMultiTenant()`, `SET app.current_tenant` without `LOCAL`) — reject with file:line if found. Check evidence files exist in `.sisyphus/evidence/`. Compare deliverables against plan.
|
||
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`
|
||
|
||
- [ ] F2. **Code Quality Review** — `unspecified-high`
|
||
Run `dotnet build` + `dotnet format --verify-no-changes` + `dotnet test` + `bun run build` + `bun run lint`. Review all changed files for: `as any`/`@ts-ignore`, empty catches, `console.log` in prod, commented-out code, unused imports, `// TODO` without ticket. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp), unnecessary null checks on non-nullable types.
|
||
Output: `Build [PASS/FAIL] | Format [PASS/FAIL] | Tests [N pass/N fail] | Lint [PASS/FAIL] | Files [N clean/N issues] | VERDICT`
|
||
|
||
- [ ] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` skill)
|
||
Start `docker compose up` from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration: login → pick club → create task → assign → transition through states → switch club → verify isolation → create shift → sign up → verify capacity. Test edge cases: invalid JWT, expired token, cross-tenant header spoof, concurrent sign-up. Save to `.sisyphus/evidence/final-qa/`.
|
||
Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT`
|
||
|
||
- [ ] F4. **Scope Fidelity Check** — `deep`
|
||
For each task: read "What to do", read actual diff (`git log`/`git diff`). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance across ALL tasks. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes. Verify no CQRS, no MediatR, no generic repo, no Swashbuckle, no social login, no recurring shifts, no notifications.
|
||
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`
|
||
|
||
---
|
||
|
||
## Commit Strategy
|
||
|
||
| Wave | Commit | Message | Files | Pre-commit |
|
||
|------|--------|---------|-------|------------|
|
||
| 1 | T1 | `chore(scaffold): initialize git repo and monorepo with .NET solution` | backend/**/*.csproj, backend/WorkClub.sln, .gitignore, .editorconfig | `dotnet build` |
|
||
| 1 | T2 | `infra(docker): add Docker Compose with PostgreSQL and Keycloak` | docker-compose.yml, backend/Dockerfile.dev | `docker compose config` |
|
||
| 1 | T3 | `infra(keycloak): configure realm with test users and club memberships` | infra/keycloak/realm-export.json | — |
|
||
| 1 | T4 | `feat(domain): add core entities — Club, Member, Task, Shift` | backend/src/WorkClub.Domain/**/*.cs | `dotnet build` |
|
||
| 1 | T5 | `chore(frontend): initialize Next.js project with Tailwind and shadcn/ui` | frontend/**, package.json, next.config.ts, tailwind.config.ts | `bun run build` |
|
||
| 1 | T6 | `infra(k8s): add Kustomize base manifests` | infra/k8s/base/**/*.yaml | `kustomize build infra/k8s/base` |
|
||
| 2 | T7+T8 | `feat(data): add EF Core DbContext, migrations, RLS policies, and multi-tenant middleware` | backend/src/WorkClub.Infrastructure/**/*.cs, backend/src/WorkClub.Api/Middleware/*.cs | `dotnet build` |
|
||
| 2 | T9 | `feat(auth): add Keycloak JWT authentication and role-based authorization` | backend/src/WorkClub.Api/Auth/*.cs, Program.cs | `dotnet build` |
|
||
| 2 | T10 | `feat(frontend-auth): add NextAuth.js Keycloak integration` | frontend/src/auth/**, frontend/src/middleware.ts | `bun run build` |
|
||
| 2 | T11 | `feat(seed): add development seed data script` | backend/src/WorkClub.Infrastructure/Seed/*.cs | `dotnet build` |
|
||
| 2 | T12 | `test(infra): add xUnit + Testcontainers + WebApplicationFactory base` | backend/tests/**/*.cs | `dotnet test` |
|
||
| 3 | T13 | `test(rls): add multi-tenant isolation integration tests` | backend/tests/WorkClub.Tests.Integration/MultiTenancy/*.cs | `dotnet test` |
|
||
| 3 | T14 | `feat(tasks): add Task CRUD API with 5-state workflow` | backend/src/WorkClub.Api/Endpoints/Tasks/*.cs, backend/src/WorkClub.Application/Tasks/*.cs | `dotnet test` |
|
||
| 3 | T15 | `feat(shifts): add Shift CRUD API with sign-up and capacity` | backend/src/WorkClub.Api/Endpoints/Shifts/*.cs, backend/src/WorkClub.Application/Shifts/*.cs | `dotnet test` |
|
||
| 3 | T16 | `feat(clubs): add Club and Member API endpoints` | backend/src/WorkClub.Api/Endpoints/Clubs/*.cs | `dotnet test` |
|
||
| 3 | T17 | `test(frontend): add Vitest + RTL + Playwright setup` | frontend/vitest.config.ts, frontend/playwright.config.ts | `bun run test` |
|
||
| 4 | T18-T21 | `feat(ui): add layout, club-switcher, login, task and shift pages` | frontend/src/app/**/*.tsx, frontend/src/components/**/*.tsx | `bun run build && bun run test` |
|
||
| 5 | T22-T25 | `infra(deploy): add full Docker Compose stack, Dockerfiles, and Kustomize dev overlay` | docker-compose.yml, **/Dockerfile*, infra/k8s/overlays/dev/**/*.yaml | `docker compose config && kustomize build infra/k8s/overlays/dev` |
|
||
| 6 | T26-T28 | `test(e2e): add Playwright E2E tests for auth, tasks, and shifts` | frontend/tests/e2e/**/*.spec.ts | `bunx playwright test` |
|
||
|
||
---
|
||
|
||
## Success Criteria
|
||
|
||
### Verification Commands
|
||
```bash
|
||
# All services start and are healthy
|
||
docker compose up -d && docker compose ps # Expected: 4 services running
|
||
|
||
# Backend health
|
||
curl -sf http://localhost:5000/health/live # Expected: 200, "Healthy"
|
||
|
||
# Keycloak OIDC discovery
|
||
curl -sf http://localhost:8080/realms/workclub/.well-known/openid-configuration # Expected: 200, JSON with "issuer"
|
||
|
||
# Get auth token
|
||
TOKEN=$(curl -sf -X POST http://localhost:8080/realms/workclub/protocol/openid-connect/token \
|
||
-d "client_id=workclub-app&username=admin@test.com&password=testpass123&grant_type=password" | jq -r '.access_token')
|
||
|
||
# Tenant isolation
|
||
curl -sf -H "Authorization: Bearer $TOKEN" -H "X-Tenant-Id: club-1-uuid" http://localhost:5000/api/tasks # Expected: 200, only club-1 tasks
|
||
curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TOKEN" -H "X-Tenant-Id: nonexistent-club" http://localhost:5000/api/tasks # Expected: 403
|
||
|
||
# Backend tests
|
||
dotnet test backend/tests/ --no-build # Expected: All pass
|
||
|
||
# Frontend build + tests
|
||
bun run build # Expected: Exit 0
|
||
bun run test # Expected: All pass
|
||
|
||
# K8s manifests valid
|
||
kustomize build infra/k8s/overlays/dev > /dev/null # Expected: Exit 0
|
||
```
|
||
|
||
### Final Checklist
|
||
- [ ] All "Must Have" items present and verified
|
||
- [ ] All "Must NOT Have" items absent (no MediatR, no generic repo, no Swashbuckle, etc.)
|
||
- [ ] All backend tests pass (`dotnet test`)
|
||
- [ ] All frontend tests pass (`bun run test`)
|
||
- [ ] All E2E tests pass (`bunx playwright test`)
|
||
- [ ] Docker Compose stack starts clean and healthy
|
||
- [ ] Kustomize manifests build without errors
|
||
- [ ] RLS isolation proven at database level
|
||
- [ ] Cross-tenant access returns 403
|
||
- [ ] Task state machine rejects invalid transitions (422)
|
||
- [ ] Shift sign-up respects capacity (409 when full)
|