# Tasks: User Preferences API ## Task Order (dependency sequence) ### T1: Domain layer - preference types, validation, defaults, and errors - **ID:** task-001 - **Scope:** Create the pure domain model for user preferences. Define `UserPreferences` struct with typed fields (`Theme`, `Language`, `NotificationPreferences`), enum types (`Theme`, `DigestFrequency`) with constants, a `DefaultPreferences()` factory, a `Validate()` method with domain-level validation, and domain error sentinels (`ErrInvalidTheme`, `ErrInvalidLanguage`, `ErrInvalidDigest`). - **Files:** - `services/preferences-api/internal/domain/preferences.go` (create) - `services/preferences-api/internal/domain/errors.go` (replace contents) - **Depends on:** None - **Acceptance criteria:** - [ ] `UserPreferences` struct has fields: UserID, Theme, Language, Notifications, UpdatedAt - [ ] `Theme` type with constants: `ThemeLight`, `ThemeDark`, `ThemeSystem` - [ ] `DigestFrequency` type with constants: `DigestNone`, `DigestDaily`, `DigestWeekly` - [ ] `NotificationPreferences` struct with Email (bool), Push (bool), Digest (DigestFrequency) - [ ] `DefaultPreferences(userID)` returns correct defaults (theme=system, language=en, email=true, push=true, digest=weekly) - [ ] `Validate()` returns `ErrInvalidTheme` for invalid theme values - [ ] `Validate()` returns `ErrInvalidLanguage` for invalid language values (only en, fr, es, de, ja) - [ ] `Validate()` returns `ErrInvalidDigest` for invalid digest values - [ ] Domain errors defined as package-level sentinel errors - [ ] No external dependencies (pure Go, no framework imports) ### T2: Port layer - PreferenceRepository interface - **ID:** task-002 - **Scope:** Define the `PreferenceRepository` port interface with `Get(ctx, userID)` and `Upsert(ctx, prefs)` methods. This is the hexagonal architecture boundary between the service layer and storage adapters. - **Files:** - `services/preferences-api/internal/port/preferences.go` (create) - **Depends on:** T1 - **Acceptance criteria:** - [ ] `PreferenceRepository` interface defined with `Get(ctx context.Context, userID string) (*domain.UserPreferences, error)` method - [ ] `PreferenceRepository` interface includes `Upsert(ctx context.Context, prefs *domain.UserPreferences) error` method - [ ] `Get` documents that it returns `nil, nil` when no preferences exist (service applies defaults) - [ ] Interface uses domain types only (no adapter-specific types) ### T3: Adapter layer - in-memory PreferenceRepository for tests - **ID:** task-003 - **Scope:** Implement a thread-safe in-memory `PreferenceRepository` adapter for use in unit and handler tests. Uses a `map[string]*domain.UserPreferences` with `sync.RWMutex`. - **Files:** - `services/preferences-api/internal/adapter/memory/preferences.go` (create) - **Depends on:** T1, T2 - **Acceptance criteria:** - [ ] Implements `port.PreferenceRepository` interface (compile-time verification) - [ ] `Get()` returns `nil, nil` for non-existent user (not an error) - [ ] `Get()` returns a defensive copy (mutation-safe) - [ ] `Upsert()` stores preferences, overwriting any existing entry - [ ] Thread-safe via `sync.RWMutex` - [ ] Constructor `NewPreferenceRepository()` initializes empty map ### T4: Adapter layer - PostgreSQL PreferenceRepository with schema creation - **ID:** task-004 - **Scope:** Implement the PostgreSQL adapter for `PreferenceRepository`. Includes `EnsureSchema()` for idempotent table creation via `CREATE TABLE IF NOT EXISTS`, `Get()` with single-row PK lookup, and `Upsert()` with `INSERT ... ON CONFLICT UPDATE`. Uses `database/sql` with parameterized queries. - **Files:** - `services/preferences-api/internal/adapter/postgres/preferences.go` (create) - **Depends on:** T1, T2 - **Acceptance criteria:** - [ ] Implements `port.PreferenceRepository` interface (compile-time verification) - [ ] Constructor accepts `*sql.DB` and calls `EnsureSchema()` to create the `user_preferences` table - [ ] `EnsureSchema()` uses `CREATE TABLE IF NOT EXISTS` with correct column types (TEXT PK, TEXT, BOOLEAN, TIMESTAMPTZ) - [ ] `Get()` returns `nil, nil` when `sql.ErrNoRows` (not an error, service applies defaults) - [ ] `Get()` maps flat columns back to `domain.UserPreferences` struct - [ ] `Upsert()` uses `INSERT ... ON CONFLICT (user_id) DO UPDATE SET ...` for atomic upsert - [ ] All queries use parameterized placeholders (`$1`, `$2`, ...) - no SQL interpolation - [ ] `updated_at` set to `NOW()` on upsert ### T5: Service layer - PreferenceService with business logic and tests - **ID:** task-005 - **Scope:** Implement `PreferenceService` with `GetPreferences(ctx, userID)` and `UpdatePreferences(ctx, userID, prefs)`. GET applies defaults when repository returns nil. UPDATE validates via domain `Validate()` before persisting. Write comprehensive unit tests using the in-memory adapter. - **Files:** - `services/preferences-api/internal/service/preferences.go` (create) - `services/preferences-api/internal/service/preferences_test.go` (create) - **Depends on:** T1, T2, T3 - **Acceptance criteria:** - [ ] `GetPreferences()` returns default preferences when repository returns nil - [ ] `GetPreferences()` returns stored preferences when they exist - [ ] `UpdatePreferences()` calls `Validate()` and returns domain errors on invalid input - [ ] `UpdatePreferences()` sets UserID and UpdatedAt before upserting - [ ] `UpdatePreferences()` delegates to repository `Upsert()` after validation - [ ] Constructor accepts `port.PreferenceRepository` and `*logging.Logger` - [ ] Unit tests cover: get defaults, get existing, update valid, update invalid theme/language/digest - [ ] Tests use `logging.Nop()` for no-op logger ### T6: Handler layer - GET and PUT preference handlers with tests - **ID:** task-006 - **Scope:** Implement `PreferenceHandler` with `Get(w, r) error` and `Update(w, r) error`. GET extracts `{user_id}` from URL, checks authorization (self-access or admin read), delegates to service, returns response envelope. PUT extracts user_id, checks self-only auth, binds/validates request body strictly, delegates to service. Write handler tests using httptest with the in-memory adapter. - **Files:** - `services/preferences-api/internal/api/handlers/preferences.go` (create) - `services/preferences-api/internal/api/handlers/preferences_test.go` (create) - **Depends on:** T1, T2, T3, T5 - **Acceptance criteria:** - [ ] `Get()` extracts `user_id` from chi URL param using brace syntax - [ ] `Get()` returns 403 Forbidden when JWT user ID != URL user_id (and not admin) - [ ] `Get()` allows admin role to read any user's preferences - [ ] `Get()` returns 200 with `{data, meta}` envelope via `httpresponse.OK()` - [ ] `Update()` returns 403 for any non-self access (even admin) - [ ] `Update()` uses strict binding to reject unknown JSON fields with 400 - [ ] `Update()` returns 400 with validation errors for invalid values - [ ] `Update()` returns 200 with updated preferences on success - [ ] Request DTOs use struct validation tags (`oneof=light dark system`, etc.) - [ ] Response DTO maps domain types to JSON representation - [ ] Domain errors mapped to appropriate HTTP errors (400, 403) - [ ] Tests cover: self-access GET, admin GET, forbidden GET, self PUT, forbidden PUT, invalid body, unknown fields, valid update ### T7: Routes, OpenAPI spec, and main.go wiring - **ID:** task-007 - **Scope:** Update `routes.go` to register preference endpoints (GET and PUT under auth middleware). Update `spec.go` to document preference endpoints with schemas, examples, and error responses. Update `main.go` to wire PostgreSQL adapter (via `DatabaseConfig`), create `PreferenceService`, and pass to route registration. - **Files:** - `services/preferences-api/internal/api/routes.go` (modify) - `services/preferences-api/internal/api/spec.go` (modify) - `services/preferences-api/cmd/server/main.go` (modify) - `services/preferences-api/internal/config/config.go` (modify if needed) - **Depends on:** T4, T5, T6 - **Acceptance criteria:** - [ ] `GET /api/preferences-api/preferences/{user_id}` registered with auth middleware - [ ] `PUT /api/preferences-api/preferences/{user_id}` registered with auth middleware - [ ] Health endpoint preserved at `/api/preferences-api/health` - [ ] OpenAPI spec documents both preference endpoints with request/response schemas - [ ] OpenAPI spec includes `UserPreferences` schema, `UpdatePreferencesRequest` schema - [ ] OpenAPI spec includes error responses (400, 401, 403) - [ ] `main.go` opens PostgreSQL connection via `sql.Open` with `DatabaseConfig.DSN()` - [ ] `main.go` creates PostgreSQL adapter, PreferenceService, passes to RegisterRoutes - [ ] Docs mounted via `application.EnableDocs(spec)` ### T8: Remove example scaffold and verify clean build - **ID:** task-008 - **Scope:** Delete all example scaffold files that have been replaced by preference equivalents. Verify the service compiles, all tests pass, and no dead code remains. - **Files:** - `services/preferences-api/internal/domain/example.go` (delete) - `services/preferences-api/internal/port/example.go` (delete) - `services/preferences-api/internal/adapter/memory/example.go` (delete) - `services/preferences-api/internal/service/example.go` (delete) - `services/preferences-api/internal/service/example_test.go` (delete) - `services/preferences-api/internal/api/handlers/example.go` (delete) - `services/preferences-api/internal/api/handlers/example_test.go` (delete) - **Depends on:** T7 - **Acceptance criteria:** - [ ] All example files deleted (7 files listed above) - [ ] No remaining imports of example types in any file - [ ] `go build ./...` succeeds with zero errors - [ ] `go test ./...` passes all tests - [ ] `go vet ./...` reports no issues - [ ] No dead code or unused imports remain