diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 541917a..3a3b6c3 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -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 - preference types, validation, defaults, and domain errors + status: pending + - id: task-002 + title: Port layer - PreferenceRepository interface and row type + status: pending + - id: task-003 + title: Service layer - PreferenceService with get, update, validation logic and unit tests + status: pending + - id: task-004 + title: Database migration - create user_preferences table + status: pending + - id: task-005 + title: PostgreSQL adapter - implement PreferenceRepository with sqlx + status: pending + - id: task-006 + title: Handler layer - GET and PUT preference handlers with error mapping and handler tests + status: pending + - id: task-007 + title: Routes, OpenAPI spec, and main.go wiring + status: pending + - id: task-008 + title: Cleanup - remove example scaffold files + status: pending diff --git a/.sdlc/features/user-preferences/tasks.md b/.sdlc/features/user-preferences/tasks.md new file mode 100644 index 0000000..1b49265 --- /dev/null +++ b/.sdlc/features/user-preferences/tasks.md @@ -0,0 +1,127 @@ +# 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/`