slack5-1770541397/.sdlc/features/user-preferences/tasks.md
rdev-worker 9db9b8bbb6
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /breakdown-feature user-preferences
2026-02-08 09:14:54 +00:00

11 KiB

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)