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