# Tasks: User Preferences API ## Task Order (dependency sequence) ### T1: Domain layer - preference types, validation, defaults, and domain errors - **Scope:** Create the core domain model: `PreferenceKey` constants, `PreferenceDefinition` registry with default values and per-key validators, `UserPreferences` aggregate, helper functions (`DefaultPreferences`, `ValidateKey`, `ValidateValue`, `MergeWithDefaults`, `SerializeForResponse`), and domain errors (`ErrUnknownPreferenceKey`, `ErrInvalidPreferenceValue`, `ErrInvalidUserID`). Also implement `ValidateUserID` (UUID format check). - **Files:** - Create `services/preferences-api/internal/domain/preference.go` - Modify `services/preferences-api/internal/domain/errors.go` (replace example errors) - Delete `services/preferences-api/internal/domain/example.go` - **Depends on:** None - **Acceptance criteria:** - [ ] `PreferenceKey` constants defined for `theme`, `language`, `notifications_enabled` - [ ] `DefaultPreferences()` returns all 3 keys with correct defaults (`system`, `en`, `true`) - [ ] `ValidateKey()` rejects unknown keys, accepts known keys - [ ] `ValidateValue()` enforces: theme in {light, dark, system}, language matches BCP 47 regex, notifications_enabled in {true, false} - [ ] `MergeWithDefaults()` fills missing keys with defaults, preserves stored values - [ ] `SerializeForResponse()` converts `"true"`/`"false"` to boolean for `notifications_enabled` - [ ] Domain errors are sentinel errors with descriptive messages - [ ] `ValidateUserID()` accepts valid UUIDs, rejects non-UUID strings ### T2: Port layer - PreferenceRepository interface and row type - **Scope:** Define the `PreferenceRepository` port interface with `GetByUserID` and `Upsert` methods, plus the `PreferenceRow` data transfer type. - **Files:** - Create `services/preferences-api/internal/port/preference.go` - Delete `services/preferences-api/internal/port/example.go` - **Depends on:** T1 - **Acceptance criteria:** - [ ] `PreferenceRepository` interface defined with `GetByUserID(ctx, userID) ([]PreferenceRow, error)` and `Upsert(ctx, userID, key, value) error` - [ ] `PreferenceRow` struct has fields: `UserID`, `Key`, `Value`, `CreatedAt`, `UpdatedAt` - [ ] Interface is minimal (no delete, no list-all-users) ### T3: Service layer - PreferenceService with get, update, validation logic and unit tests - **Scope:** Implement `PreferenceService` with `GetPreferences` and `UpdatePreferences` methods. `GetPreferences` fetches stored rows, merges with defaults, serializes for response. `UpdatePreferences` validates keys/values, upserts each, then returns full merged result. Write comprehensive unit tests with a mock repository. - **Files:** - Create `services/preferences-api/internal/service/preference.go` - Create `services/preferences-api/internal/service/preference_test.go` - Delete `services/preferences-api/internal/service/example.go` - Delete `services/preferences-api/internal/service/example_test.go` - **Depends on:** T1, T2 - **Acceptance criteria:** - [ ] `GetPreferences` returns all defaults when no rows stored - [ ] `GetPreferences` merges stored values with defaults for missing keys - [ ] `GetPreferences` returns `ErrInvalidUserID` for non-UUID user_id - [ ] `UpdatePreferences` rejects unknown keys with `ErrUnknownPreferenceKey` - [ ] `UpdatePreferences` rejects invalid values with `ErrInvalidPreferenceValue` - [ ] `UpdatePreferences` calls `Upsert` for each provided key - [ ] `UpdatePreferences` returns full merged preferences after upsert - [ ] `UpdatePreferences` handles boolean input (converts `true`/`false` to string) - [ ] Unit tests cover: defaults-only, partial stored, full stored, unknown key, invalid value, invalid user_id - [ ] Tests use mock repository (no database dependency) ### T4: Database migration - create user_preferences table - **Scope:** Write the SQL migration to create the `user_preferences` table with composite primary key `(user_id, key)` and an index on `user_id`. - **Files:** - Create `services/preferences-api/migrations/001_create_user_preferences.sql` - **Depends on:** None - **Acceptance criteria:** - [ ] Table `user_preferences` created with columns: `user_id` (UUID NOT NULL), `key` (VARCHAR(64) NOT NULL), `value` (TEXT NOT NULL), `created_at` (TIMESTAMPTZ DEFAULT NOW()), `updated_at` (TIMESTAMPTZ DEFAULT NOW()) - [ ] Primary key is `(user_id, key)` - [ ] Index `idx_user_preferences_user_id` created on `user_id` - [ ] Migration is idempotent-safe (CREATE TABLE, not CREATE TABLE IF NOT EXISTS — rely on migration runner) ### T5: PostgreSQL adapter - implement PreferenceRepository with sqlx - **Scope:** Implement `PostgresPreferenceRepository` that satisfies the `PreferenceRepository` interface using sqlx queries. `GetByUserID` selects all rows for a user. `Upsert` uses `INSERT ... ON CONFLICT DO UPDATE`. - **Files:** - Create `services/preferences-api/internal/adapter/postgres/preference.go` - Delete `services/preferences-api/internal/adapter/memory/example.go` - **Depends on:** T2, T4 - **Acceptance criteria:** - [ ] `PostgresPreferenceRepository` struct holds a `*sqlx.DB` (or pool from `pkg/database`) - [ ] `GetByUserID` executes `SELECT user_id, key, value, created_at, updated_at FROM user_preferences WHERE user_id = $1` - [ ] `Upsert` executes `INSERT INTO user_preferences ... ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()` - [ ] Constructor `NewPreferenceRepository(db)` returns a new instance - [ ] Implements `port.PreferenceRepository` interface (compile-time check) ### T6: Handler layer - GET and PUT preference handlers with error mapping and handler tests - **Scope:** Implement `PreferenceHandler` with `GetPreferences` and `UpdatePreferences` HTTP handlers. GET extracts `user_id` from URL, calls service, maps errors. PUT also binds request body via `app.Bind`. Write handler tests using chi test router with a mock service or mock repository. - **Files:** - Create `services/preferences-api/internal/api/handlers/preference.go` - Create `services/preferences-api/internal/api/handlers/preference_test.go` - Delete `services/preferences-api/internal/api/handlers/example.go` - Delete `services/preferences-api/internal/api/handlers/example_test.go` - **Depends on:** T3 - **Acceptance criteria:** - [ ] GET handler extracts `user_id` via `chi.URLParam(r, "user_id")` - [ ] GET handler returns 200 with `{data, meta}` envelope containing `user_id` and `preferences` - [ ] GET handler returns 400 for invalid `user_id` - [ ] PUT handler binds request body via `app.Bind()` - [ ] PUT handler returns 200 with full merged preferences after update - [ ] PUT handler returns 400 for unknown key, invalid value, or invalid user_id - [ ] Both handlers return `error` wrapped with `app.Wrap()` - [ ] Handler tests cover: successful GET, GET with defaults, successful PUT, PUT unknown key, PUT invalid value, invalid user_id for both endpoints - [ ] Response shape matches spec: `{"data": {"user_id": "...", "preferences": {...}}, "meta": {...}}` ### T7: Routes, OpenAPI spec, and main.go wiring - **Scope:** Update `routes.go` to register new preference routes (replacing example routes). Update `spec.go` to document the new endpoints with schemas. Update `main.go` to wire database connection, run migrations, create PostgreSQL adapter, and inject into the service. - **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:** T5, T6 - **Acceptance criteria:** - [ ] Routes: `GET /api/preferences-api/preferences/{user_id}` registered - [ ] Routes: `PUT /api/preferences-api/preferences/{user_id}` registered - [ ] Routes: Old `/examples` routes removed - [ ] Routes: Preference routes in auth-protectable group (conditional on `AUTH_ENABLED`) - [ ] Routes: Health endpoint unchanged - [ ] OpenAPI spec documents both endpoints with request/response schemas - [ ] OpenAPI spec includes `UserPreferences` and `UpdatePreferencesRequest` schemas - [ ] `main.go` connects to database via `pkg/database` - [ ] `main.go` runs migrations on startup - [ ] `main.go` creates `PostgresPreferenceRepository` and injects into service - [ ] `main.go` adds DB pool shutdown hook ### T8: Cleanup - remove example scaffold files - **Scope:** Delete all remaining example scaffold files that were not already replaced in previous tasks. Verify no references to `example` or `Example` remain in the codebase under `services/preferences-api/`. Ensure the service compiles and all tests pass. - **Files:** - Delete any remaining `example*.go` files - Delete `services/preferences-api/internal/adapter/memory/` directory (if not already removed) - **Depends on:** T7 - **Acceptance criteria:** - [ ] No files named `example*.go` exist under `services/preferences-api/` - [ ] No references to `Example`, `example`, or `memory.New` in `services/preferences-api/` - [ ] `go build ./...` succeeds from `services/preferences-api/` - [ ] `go test ./...` passes from `services/preferences-api/` - [ ] `go vet ./...` passes from `services/preferences-api/`