From 493f96a9fda4d1aced35139fc7089f603ecba3ea Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sun, 8 Feb 2026 18:24:58 +0000 Subject: [PATCH] build: /breakdown-feature user-preferences --- .sdlc/features/user-preferences/manifest.yaml | 28 +++- .sdlc/features/user-preferences/tasks.md | 142 ++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 .sdlc/features/user-preferences/tasks.md diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index ac6aca9..566ed51 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 errors + status: pending + - id: task-002 + title: Port layer - PreferenceRepository interface + status: pending + - id: task-003 + title: Adapter layer - in-memory PreferenceRepository for tests + status: pending + - id: task-004 + title: Adapter layer - PostgreSQL PreferenceRepository with schema creation + status: pending + - id: task-005 + title: Service layer - PreferenceService with business logic and tests + status: pending + - id: task-006 + title: Handler layer - GET and PUT preference handlers with tests + status: pending + - id: task-007 + title: Routes, OpenAPI spec, and main.go wiring + status: pending + - id: task-008 + title: Remove example scaffold and verify clean build + status: pending diff --git a/.sdlc/features/user-preferences/tasks.md b/.sdlc/features/user-preferences/tasks.md new file mode 100644 index 0000000..2adae6e --- /dev/null +++ b/.sdlc/features/user-preferences/tasks.md @@ -0,0 +1,142 @@ +# 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