diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index f354fc0..5b982d4 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -32,5 +32,28 @@ artifacts: approved_by: user approved_at: 2026-02-09T03:10:29.025362903Z tasks: - status: pending + status: draft path: tasks.md + total: 7 +tasks: + - id: task-001 + title: Domain layer - UserPreferences model and domain errors + status: pending + - id: task-002 + title: Port layer - PreferenceRepository interface + status: pending + - id: task-003 + title: Service layer - PreferenceService with validation logic and tests + status: pending + - id: task-004 + title: Database migration and PostgreSQL adapter + status: pending + - id: task-005 + title: HTTP handlers - GET and PUT preference endpoints with tests + status: pending + - id: task-006 + title: Routes, OpenAPI spec, and main.go wiring + status: pending + - id: task-007 + title: Cleanup - Remove example scaffolding 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..6d4c54c --- /dev/null +++ b/.sdlc/features/user-preferences/tasks.md @@ -0,0 +1,121 @@ +# Tasks: User Preferences API + +## Task Order (dependency sequence) + +### T1: Domain layer - UserPreferences model and domain errors +- **Scope:** Create the `UserPreferences` domain model and preference-specific domain errors. The model uses `map[string]any` for flexible key-value storage. Add `ErrInvalidUserID` and `ErrInvalidPreferenceValue` sentinel errors. +- **Files:** + - Create `services/preferences-api/internal/domain/preference.go` + - Modify `services/preferences-api/internal/domain/errors.go` (replace example errors with preference errors) +- **Depends on:** None +- **Acceptance criteria:** + - [ ] `UserPreferences` struct exists with fields: `UserID string`, `Preferences map[string]any`, `CreatedAt time.Time`, `UpdatedAt time.Time` + - [ ] `ErrInvalidUserID` sentinel error defined + - [ ] `ErrInvalidPreferenceValue` sentinel error defined + - [ ] Example domain errors removed + - [ ] Package compiles without errors + +### T2: Port layer - PreferenceRepository interface +- **Scope:** Define the `PreferenceRepository` interface with `Get` and `Upsert` methods. `Get` returns `nil, nil` for non-existent users (not an error). `Upsert` handles both create and update atomically. +- **Files:** + - Create `services/preferences-api/internal/port/preference.go` +- **Depends on:** T1 +- **Acceptance criteria:** + - [ ] `PreferenceRepository` interface defined with `Get(ctx context.Context, userID string) (*domain.UserPreferences, error)` method + - [ ] `PreferenceRepository` interface has `Upsert(ctx context.Context, prefs *domain.UserPreferences) error` method + - [ ] Method signatures document expected nil behavior for `Get` (nil, nil when no row exists) + - [ ] Package compiles without errors + +### T3: Service layer - PreferenceService with validation logic and tests +- **Scope:** Implement `PreferenceService` with `Get` and `Upsert` methods. The `Upsert` method validates known preference keys (`theme`, `language`, `notifications_enabled`), merges incoming preferences with existing ones, and delegates to the repository. Includes a custom `ValidationError` type that wraps `domain.ErrInvalidPreferenceValue` and carries per-field error details. 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` +- **Depends on:** T1, T2 +- **Acceptance criteria:** + - [ ] `PreferenceService` struct with constructor `NewPreferenceService(repo, logger)` + - [ ] `Get` method delegates to repository and returns result + - [ ] `Upsert` method validates `theme` against `["light", "dark", "system"]` + - [ ] `Upsert` method validates `language` as a valid BCP-47 tag + - [ ] `Upsert` method validates `notifications_enabled` as boolean + - [ ] Unknown preference keys accepted without validation + - [ ] Multiple validation errors collected and returned together in `ValidationError` + - [ ] `ValidationError` type implements `error` and `Unwrap()` returning `domain.ErrInvalidPreferenceValue` + - [ ] `ValidationError.Details` is `map[string]string` with per-field messages + - [ ] Merge strategy: incoming keys overwrite existing, unmentioned keys preserved + - [ ] Tests cover: valid preferences, invalid theme, invalid language, invalid notifications_enabled, multiple errors, unknown keys accepted, merge behavior + - [ ] All tests pass + +### T4: Database migration and PostgreSQL adapter +- **Scope:** Create the SQL migration for the `preferences` table and implement the PostgreSQL adapter for `PreferenceRepository`. Uses sqlx with parameterized queries. `Get` returns `nil, nil` on `sql.ErrNoRows`. `Upsert` uses `ON CONFLICT` for atomic create-or-update. +- **Files:** + - Create `services/preferences-api/migrations/001_create_preferences.sql` + - Create `services/preferences-api/internal/adapter/postgres/preference.go` +- **Depends on:** T1, T2 +- **Acceptance criteria:** + - [ ] Migration creates `preferences` table with columns: `user_id UUID PRIMARY KEY`, `preferences JSONB NOT NULL DEFAULT '{}'`, `created_at TIMESTAMPTZ`, `updated_at TIMESTAMPTZ` + - [ ] Migration creates index `idx_preferences_updated_at` on `updated_at` + - [ ] Migration uses `CREATE TABLE IF NOT EXISTS` for idempotency + - [ ] PostgreSQL adapter implements `PreferenceRepository` interface (compile-time check) + - [ ] `Get` uses parameterized query and returns `nil, nil` for `sql.ErrNoRows` + - [ ] `Upsert` uses `INSERT ... ON CONFLICT ... DO UPDATE` with parameterized values + - [ ] JSONB marshaling/unmarshaling handled correctly for `map[string]any` + - [ ] Package compiles without errors + +### T5: HTTP handlers - GET and PUT preference endpoints with tests +- **Scope:** Implement preference handlers for GET and PUT. GET extracts `user_id` from URL, validates UUID format, calls service, and returns response in `{data, meta}` envelope. PUT additionally binds the request body and maps validation errors to structured HTTP error responses. Write handler tests using httptest with a mock service/repository. +- **Files:** + - Create `services/preferences-api/internal/api/handlers/preference.go` + - Create `services/preferences-api/internal/api/handlers/preference_test.go` +- **Depends on:** T1, T2, T3 +- **Acceptance criteria:** + - [ ] `Preference` handler struct with constructor `NewPreference(svc, logger)` + - [ ] `Get` handler extracts `user_id` via `chi.URLParam`, validates UUID, calls service, returns `httpresponse.OK` + - [ ] `Get` handler returns 200 with empty preferences `{}` when service returns nil + - [ ] `Get` handler returns 400 for invalid UUID format + - [ ] `Upsert` handler binds request body with `app.Bind()`, validates preferences field is not nil + - [ ] `Upsert` handler calls service and returns `httpresponse.OK` with updated preferences + - [ ] `Upsert` handler maps `ValidationError` to `httperror` with details + - [ ] `PreferenceResponse` struct has `json` tags for `user_id`, `preferences`, `updated_at` + - [ ] `mapDomainError` function handles `ErrInvalidPreferenceValue` with details + - [ ] Tests cover: GET success, GET empty preferences, GET invalid UUID, PUT success, PUT validation error, PUT missing body + - [ ] All tests pass + +### T6: Routes, OpenAPI spec, and main.go wiring +- **Scope:** Update routes to register preference endpoints (replacing example routes). Update OpenAPI spec to document preference schemas and endpoints (removing example schemas). Update main.go to wire PostgreSQL connection, run migrations, create postgres adapter, and inject into the service. Add `golang.org/x/text/language` dependency for BCP-47 validation. +- **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` + - Modify `services/preferences-api/go.mod` (add `golang.org/x/text` dependency) +- **Depends on:** T3, T4, T5 +- **Acceptance criteria:** + - [ ] Routes register `GET /preferences/{user_id}` and `PUT /preferences/{user_id}` under `/api/preferences-api` + - [ ] Example routes removed + - [ ] Both routes wrapped with `app.Wrap()` + - [ ] OpenAPI spec defines `UserPreferences` schema and `UpdatePreferencesRequest` schema + - [ ] OpenAPI spec documents GET and PUT endpoints with parameters, request bodies, and response schemas + - [ ] Example schemas removed from spec + - [ ] `main.go` connects to PostgreSQL via `database.MustConnect` + - [ ] `main.go` runs migrations via `database.MustRunMigrations` + - [ ] `main.go` creates `postgres.NewPreferenceRepository` and `service.NewPreferenceService` + - [ ] `main.go` defers `pool.Close()` + - [ ] `golang.org/x/text/language` dependency added + - [ ] Service compiles and starts successfully + +### T7: Cleanup - Remove example scaffolding files +- **Scope:** Delete all example-related files that have been replaced by preference implementations. This is done last to ensure nothing breaks. +- **Files:** + - Delete `services/preferences-api/internal/domain/example.go` + - Delete `services/preferences-api/internal/service/example.go` + - Delete `services/preferences-api/internal/service/example_test.go` + - Delete `services/preferences-api/internal/port/example.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:** T6 +- **Acceptance criteria:** + - [ ] All example files deleted + - [ ] No imports reference example types + - [ ] `go test ./...` passes with no compilation errors + - [ ] Service compiles cleanly