From 9db9b8bbb6c343f2401c3a9b4d70442dca9e0f17 Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sun, 8 Feb 2026 09:14:54 +0000 Subject: [PATCH] build: /breakdown-feature user-preferences --- .sdlc/features/user-preferences/manifest.yaml | 28 +++- .sdlc/features/user-preferences/tasks.md | 150 ++++++++++++++++++ 2 files changed, 177 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 b9c2161..6664d3e 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 - preferences types, validation, defaults, and errors + status: pending + - id: task-002 + title: Port layer - PreferencesRepository interface + status: pending + - id: task-003 + title: Database migration - user_preferences table + status: pending + - id: task-004 + title: PostgreSQL adapter - PreferencesRepository implementation + status: pending + - id: task-005 + title: Service layer - PreferencesService with get/set logic and tests + status: pending + - id: task-006 + title: HTTP handlers - GET and PUT with auth, mapping, and tests + status: pending + - id: task-007 + title: Routes, OpenAPI spec, and main.go wiring + status: pending + - id: task-008 + title: Remove example scaffold code + status: pending diff --git a/.sdlc/features/user-preferences/tasks.md b/.sdlc/features/user-preferences/tasks.md new file mode 100644 index 0000000..1066995 --- /dev/null +++ b/.sdlc/features/user-preferences/tasks.md @@ -0,0 +1,150 @@ +# 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)