build: /breakdown-feature user-preferences
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
rdev-worker 2026-02-08 09:14:54 +00:00
parent 100b3c4035
commit 9db9b8bbb6
2 changed files with 177 additions and 1 deletions

View File

@ -25,5 +25,31 @@ artifacts:
status: draft
path: spec.md
tasks:
status: pending
status: draft
path: tasks.md
total: 8
tasks:
- id: task-001
title: Domain layer - preferences types, validation, defaults, and errors
status: pending
- id: task-002
title: Port layer - PreferencesRepository interface
status: pending
- id: task-003
title: Database migration - user_preferences table
status: pending
- id: task-004
title: PostgreSQL adapter - PreferencesRepository implementation
status: pending
- id: task-005
title: Service layer - PreferencesService with get/set logic and tests
status: pending
- id: task-006
title: HTTP handlers - GET and PUT with auth, mapping, and tests
status: pending
- id: task-007
title: Routes, OpenAPI spec, and main.go wiring
status: pending
- id: task-008
title: Remove example scaffold code
status: pending

View File

@ -0,0 +1,150 @@
# Tasks: User Preferences API
## Task Order (dependency sequence)
### T1: Domain layer - preferences types, validation, defaults, and errors
- **Scope:** Create the pure domain model for user preferences. Define `UserID`, `NotificationPreferences`, `Preferences` (with custom JSON marshal/unmarshal for unknown key preservation via `Extra` map), and `UserPreferences` types. Implement `DefaultPreferences()` as the single source of truth for defaults. Implement `Preferences.Validate()` with theme enum and language length checks. Add domain errors: `ErrInvalidTheme`, `ErrInvalidLanguage`, `ErrForbidden`, `ErrPreferencesNotFound`.
- **Files:**
- Create: `services/preferences-api/internal/domain/preferences.go`
- Modify: `services/preferences-api/internal/domain/errors.go`
- **Depends on:** None
- **Acceptance criteria:**
- [ ] `UserPreferences`, `Preferences`, `NotificationPreferences`, `UserID` types defined
- [ ] `DefaultPreferences()` returns theme=`"system"`, language=`"en"`, notifications email=true, push=true, sms=false
- [ ] `Validate()` rejects theme values not in `["light", "dark", "system"]` with `ErrInvalidTheme`
- [ ] `Validate()` allows empty theme (treated as valid, will use default)
- [ ] `Validate()` rejects language longer than 10 runes with `ErrInvalidLanguage`
- [ ] Custom `MarshalJSON`/`UnmarshalJSON` on `Preferences` preserves unknown keys via `Extra` map
- [ ] `ErrInvalidTheme`, `ErrInvalidLanguage`, `ErrForbidden`, `ErrPreferencesNotFound` defined in errors.go
- [ ] No external dependencies (pure domain)
### T2: Port layer - PreferencesRepository interface
- **Scope:** Define the `PreferencesRepository` interface with `Get` and `Upsert` methods. This is the contract between the service layer and the persistence adapter.
- **Files:**
- Create: `services/preferences-api/internal/port/preferences.go`
- **Depends on:** T1
- **Acceptance criteria:**
- [ ] `PreferencesRepository` interface defined with `Get(ctx, userID) (*domain.UserPreferences, error)` and `Upsert(ctx, prefs *domain.UserPreferences) error`
- [ ] Uses `domain.UserID` and `domain.UserPreferences` types from T1
- [ ] No implementation, interface only
### T3: Database migration - user_preferences table
- **Scope:** Create the SQL migration file for the `user_preferences` table with `user_id TEXT PRIMARY KEY`, `preferences JSONB NOT NULL`, `created_at TIMESTAMPTZ`, and `updated_at TIMESTAMPTZ`. Set up the `//go:embed` migration embedding in a migrations package.
- **Files:**
- Create: `services/preferences-api/migrations/001_create_user_preferences.sql`
- Create or modify: `services/preferences-api/migrations/migrations.go` (embed directive)
- **Depends on:** None
- **Acceptance criteria:**
- [ ] Migration creates `user_preferences` table with `IF NOT EXISTS`
- [ ] `user_id TEXT PRIMARY KEY` column
- [ ] `preferences JSONB NOT NULL` column
- [ ] `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` column
- [ ] `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` column
- [ ] Migration file embedded via `//go:embed` per project convention
### T4: PostgreSQL adapter - PreferencesRepository implementation
- **Scope:** Implement the `postgres.PreferencesRepository` struct that satisfies the `port.PreferencesRepository` interface. `Get` queries by `user_id` and returns `domain.ErrPreferencesNotFound` when no row exists. `Upsert` uses `INSERT ... ON CONFLICT (user_id) DO UPDATE SET` for idempotent writes. JSON marshaling between `domain.Preferences` and the JSONB column.
- **Files:**
- Create: `services/preferences-api/internal/adapter/postgres/preferences.go`
- **Depends on:** T1, T2, T3
- **Acceptance criteria:**
- [ ] `postgres.PreferencesRepository` struct with `*sqlx.DB` and logger
- [ ] `NewPreferencesRepository(db, logger)` constructor
- [ ] `Get` returns `domain.ErrPreferencesNotFound` when no row found (`sql.ErrNoRows`)
- [ ] `Get` unmarshals JSONB into `domain.Preferences` correctly
- [ ] `Upsert` uses parameterized `INSERT ... ON CONFLICT DO UPDATE` query
- [ ] `Upsert` marshals `domain.Preferences` to JSON for JSONB storage
- [ ] All queries use parameterized statements (no SQL injection)
### T5: Service layer - PreferencesService with get/set logic and tests
- **Scope:** Implement `PreferencesService` with `GetPreferences` and `SetPreferences` methods. `GetPreferences` returns defaults when the repo returns `ErrPreferencesNotFound`. `SetPreferences` validates via domain, builds the entity, and upserts. Write comprehensive unit tests with a mock repository.
- **Files:**
- Create: `services/preferences-api/internal/service/preferences.go`
- Create: `services/preferences-api/internal/service/preferences_test.go`
- **Depends on:** T1, T2
- **Acceptance criteria:**
- [ ] `PreferencesService` struct with `port.PreferencesRepository` and logger
- [ ] `NewPreferencesService(repo, logger)` constructor
- [ ] `GetPreferences` returns stored preferences when they exist
- [ ] `GetPreferences` returns `DefaultPreferences()` with given `userID` when repo returns `ErrPreferencesNotFound`
- [ ] `SetPreferences` calls `Validate()` on input and returns domain errors for invalid data
- [ ] `SetPreferences` calls `repo.Upsert()` and returns the saved entity
- [ ] Unit tests use a mock `PreferencesRepository`
- [ ] Tests cover: get with existing prefs, get with defaults, set valid prefs, set with invalid theme, set with invalid language
- [ ] Tests use `logging.Nop()` for logger
### T6: HTTP handlers - GET and PUT with auth, mapping, and tests
- **Scope:** Implement the `Preferences` handler struct with `Get` and `Put` methods. Both extract `user_id` from URL params, perform authorization check (authenticated user matches path `user_id`), and delegate to the service. Request/response DTO types for JSON serialization. `mapDomainError()` function to convert domain errors to `httperror.*`. Handler unit tests with mocked service.
- **Files:**
- Create: `services/preferences-api/internal/api/handlers/preferences.go`
- Create: `services/preferences-api/internal/api/handlers/preferences_test.go`
- **Depends on:** T1, T5
- **Acceptance criteria:**
- [ ] `Preferences` handler struct with service and logger
- [ ] `NewPreferences(svc, logger)` constructor
- [ ] `Get` handler: extracts `user_id` via `chi.URLParam`, checks auth via `auth.GetUser`, returns 403 if user mismatch, returns preferences via `httpresponse.OK`
- [ ] `Put` handler: extracts `user_id`, checks auth, binds request via `app.BindAndValidate`, delegates to service, returns saved preferences via `httpresponse.OK`
- [ ] Request DTOs: `PutPreferencesRequest`, `PreferencesPayload`, `NotificationPreferencesPayload`
- [ ] Response DTO: `PreferencesResponse` with `user_id`, `preferences`, `updated_at`
- [ ] `mapDomainError()` maps `ErrInvalidTheme` and `ErrInvalidLanguage` to `httperror.BadRequest`
- [ ] `toResponse()` and `toDomain()` conversion functions
- [ ] Tests cover: GET success, GET defaults, GET forbidden, PUT success, PUT validation error, PUT forbidden
### T7: Routes, OpenAPI spec, and main.go wiring
- **Scope:** Update `routes.go` to register authenticated preference routes (`GET /{user_id}`, `PUT /{user_id}`) under `/api/preferences-api/preferences` with `auth.Middleware()`. Update `spec.go` with preference schemas and endpoint documentation. Update `main.go` to connect to PostgreSQL via `database.Connect()`, run migrations, create the Postgres adapter, wire the service, and register cleanup on shutdown.
- **Files:**
- Modify: `services/preferences-api/internal/api/routes.go`
- Modify: `services/preferences-api/internal/api/spec.go`
- Modify: `services/preferences-api/cmd/server/main.go`
- **Depends on:** T3, T4, T5, T6
- **Acceptance criteria:**
- [ ] Routes registered: `GET /api/preferences-api/preferences/{user_id}` and `PUT /api/preferences-api/preferences/{user_id}`
- [ ] Auth middleware applied to preference route group (conditional on `AuthEnabled`)
- [ ] URL parameters use brace syntax `{user_id}`
- [ ] OpenAPI spec defines `Preferences`, `NotificationPreferences`, `UserPreferencesResponse`, `PutPreferencesRequest` schemas
- [ ] OpenAPI spec documents both endpoints with auth requirements and error responses
- [ ] `main.go` connects to PostgreSQL with `database.Connect()`
- [ ] `main.go` runs migrations via `database.MustRunMigrations()`
- [ ] `main.go` creates postgres repo, preferences service, and passes to route registration
- [ ] `main.go` registers `pool.Close()` for graceful shutdown
- [ ] Health check route preserved at `/api/preferences-api/health`
### T8: Remove example scaffold code
- **Scope:** Delete all example/scaffold files that are replaced by the preferences implementation. This is the final cleanup task.
- **Files:**
- Delete: `services/preferences-api/internal/domain/example.go`
- Delete: `services/preferences-api/internal/port/example.go`
- Delete: `services/preferences-api/internal/service/example.go`
- Delete: `services/preferences-api/internal/service/example_test.go`
- Delete: `services/preferences-api/internal/adapter/memory/example.go`
- Delete: `services/preferences-api/internal/api/handlers/example.go`
- Delete: `services/preferences-api/internal/api/handlers/example_test.go`
- **Depends on:** T7 (all new code is in place before deleting old code)
- **Acceptance criteria:**
- [ ] All 7 example files deleted
- [ ] `internal/adapter/memory/` directory removed (no longer needed)
- [ ] No remaining references to `Example`, `ExampleService`, `ExampleRepository` in the codebase
- [ ] Service compiles cleanly (`go build ./...`)
- [ ] All tests pass (`go test ./...`)
## Dependency Graph
```
T1 (Domain) ──┬──▶ T2 (Port) ──┬──▶ T5 (Service) ──┬──▶ T6 (Handlers) ──┐
│ │ │ │
│ └──▶ T4 (Adapter) ──┐│ │
│ ││ │
T3 (Migration)─────────────────────▶ T4 ───────────┘│ │
│ │
└──▶ T7 (Wiring) ───┤
T8 (Cleanup) ◀──┘
```
**Longest dependency chain:** T1 → T2 → T5 → T6 → T7 → T8 (depth: 6)
**Parallelization opportunities:**
- T1 and T3 can be done in parallel (no dependencies)
- T2 can start immediately after T1
- T4 can start after T1 + T2 + T3
- T5 can start after T1 + T2 (doesn't need T3 or T4)