From 100b3c4035784d2f6a3f3803ae04d9c6be3148eb Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sun, 8 Feb 2026 09:12:44 +0000 Subject: [PATCH] build: /design-feature user-preferences --- .sdlc/features/user-preferences/design.md | 648 ++++++++++++++++++ .sdlc/features/user-preferences/manifest.yaml | 2 +- 2 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 .sdlc/features/user-preferences/design.md diff --git a/.sdlc/features/user-preferences/design.md b/.sdlc/features/user-preferences/design.md new file mode 100644 index 0000000..b6fcd11 --- /dev/null +++ b/.sdlc/features/user-preferences/design.md @@ -0,0 +1,648 @@ +# Design: User Preferences API + +## Architecture Approach + +The feature replaces the existing example/scaffold CRUD resource in `preferences-api` with a real user preferences domain. The hexagonal architecture already in place is preserved — only the inner layers change. + +**What changes:** +- **Domain layer** — New `UserPreferences` entity with validation, replacing `Example` +- **Port layer** — New `PreferencesRepository` interface, replacing `ExampleRepository` +- **Service layer** — New `PreferencesService` with get/upsert logic, replacing `ExampleService` +- **Adapter layer** — New PostgreSQL adapter (replacing in-memory `Example` adapter) +- **Handler layer** — Two new handlers (GET, PUT), replacing five example handlers +- **Routes** — New authenticated route group at `/api/preferences-api/preferences/{user_id}` +- **OpenAPI spec** — Updated with preferences schemas and endpoints +- **Migrations** — New SQL migration for `user_preferences` table +- **main.go** — Updated to wire database pool and new dependencies + +**What is removed:** +- All `example` domain, port, service, adapter, handler, and test code +- The in-memory adapter (production uses PostgreSQL) + +**What is unchanged:** +- Health check handler and route +- `config/config.go` (already supports DATABASE_URL, AUTH_ENABLED, JWT_SECRET) +- Dockerfile, Makefile, component.yaml, go.mod structure + +## Data Model Changes + +### New Domain Types + +```go +// domain/preferences.go + +type UserID string + +type NotificationPreferences struct { + Email bool + Push bool + SMS bool +} + +type Preferences struct { + Theme string + Language string + Notifications NotificationPreferences +} + +type UserPreferences struct { + UserID UserID + Preferences Preferences + UpdatedAt time.Time +} +``` + +### Default Values + +When no preferences exist for a user, the service returns defaults: + +| Key | Default | +|-----|---------| +| `theme` | `"system"` | +| `language` | `"en"` | +| `notifications.email` | `true` | +| `notifications.push` | `true` | +| `notifications.sms` | `false` | + +Defaults are defined as a function in the domain layer (`DefaultPreferences()`) — the single source of truth. + +### Database Schema + +**Table: `user_preferences`** + +```sql +CREATE TABLE IF NOT EXISTS user_preferences ( + user_id TEXT PRIMARY KEY, + preferences JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**Design decisions:** +- `user_id` as `TEXT PRIMARY KEY` — no UUID type constraint; IDs come from the auth system +- `preferences` as `JSONB` — single document per user, supporting the spec's extensibility requirement (unknown keys preserved) +- `created_at` included for operational debugging even though it's not exposed in the API +- No foreign key to a users table — preferences-api is a standalone service + +### Migration File + +`migrations/001_create_user_preferences.sql` + +Single idempotent migration using `IF NOT EXISTS`. Embedded via `//go:embed` per project convention. + +## API Changes + +### Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/api/preferences-api/preferences/{user_id}` | Required | Get preferences (returns defaults if none saved) | +| `PUT` | `/api/preferences-api/preferences/{user_id}` | Required | Create or replace preferences | + +### GET `/api/preferences-api/preferences/{user_id}` + +**Authorization:** Authenticated user's ID must match `{user_id}`, else 403. + +**Behavior:** +1. Extract `user_id` from URL path +2. Verify authenticated user matches `user_id` +3. Query database for preferences +4. If no row exists, return default preferences +5. Return response with `{data, meta}` envelope + +**Response (200):** +```json +{ + "data": { + "user_id": "usr_abc123", + "preferences": { + "theme": "dark", + "language": "en", + "notifications": { + "email": true, + "push": true, + "sms": false + } + }, + "updated_at": "2026-02-08T10:30:00Z" + }, + "meta": { + "request_id": "...", + "timestamp": "..." + } +} +``` + +**When no preferences saved (200 with defaults):** +```json +{ + "data": { + "user_id": "usr_abc123", + "preferences": { + "theme": "system", + "language": "en", + "notifications": { + "email": true, + "push": true, + "sms": false + } + }, + "updated_at": "0001-01-01T00:00:00Z" + }, + "meta": { ... } +} +``` + +The `updated_at` zero value signals "never saved". Alternatively, it could be omitted when returning defaults — but including it keeps the response shape consistent. + +### PUT `/api/preferences-api/preferences/{user_id}` + +**Authorization:** Authenticated user's ID must match `{user_id}`, else 403. + +**Behavior:** +1. Extract `user_id` from URL path +2. Verify authenticated user matches `user_id` +3. Bind and validate request body +4. Run domain validation on known keys +5. Upsert into database (INSERT ON CONFLICT UPDATE) +6. Return saved preferences with `{data, meta}` envelope + +**Request body:** +```json +{ + "preferences": { + "theme": "dark", + "language": "en", + "notifications": { + "email": true, + "push": true, + "sms": false + } + } +} +``` + +**Validation rules (domain layer):** +- `theme`: Must be one of `"light"`, `"dark"`, `"system"` — if present +- `language`: Max 10 characters — if present +- Unknown top-level keys in `preferences`: preserved (per spec extensibility requirement) +- `notifications` sub-keys: booleans, no special validation needed (Go zero-value is `false`) + +**Response (200):** +Same shape as GET response, with the just-saved data and current timestamp. + +**Error (400):** +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "invalid theme: must be one of light, dark, system" + }, + "meta": { ... } +} +``` + +## Component Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ HTTP Layer │ +│ │ +│ auth.Middleware() ──▶ handlers.Preferences │ +│ │ │ +│ GET /preferences/{user_id} ──▶ Get() ──▶ httpresponse.OK() │ +│ PUT /preferences/{user_id} ──▶ Put() ──▶ httpresponse.OK() │ +│ │ │ +│ mapDomainError() ──▶ httperror.* │ +└────────────────┬─────────────────────────────────────────────────┘ + │ +┌────────────────▼─────────────────────────────────────────────────┐ +│ Service Layer │ +│ │ +│ PreferencesService │ +│ ├── GetPreferences(ctx, userID) → *UserPreferences, error │ +│ │ └── returns defaults if repo returns ErrNotFound │ +│ └── SetPreferences(ctx, userID, prefs) → *UserPreferences, err│ +│ └── validates, then upserts via repo │ +└────────────────┬─────────────────────────────────────────────────┘ + │ +┌────────────────▼─────────────────────────────────────────────────┐ +│ Port Layer (Interface) │ +│ │ +│ PreferencesRepository │ +│ ├── Get(ctx, userID) → *UserPreferences, error │ +│ └── Upsert(ctx, prefs *UserPreferences) → error │ +└────────────────┬─────────────────────────────────────────────────┘ + │ +┌────────────────▼─────────────────────────────────────────────────┐ +│ Adapter Layer (PostgreSQL) │ +│ │ +│ postgres.PreferencesRepository │ +│ ├── Get() → SELECT ... WHERE user_id = $1 │ +│ └── Upsert() → INSERT ... ON CONFLICT (user_id) │ +│ DO UPDATE SET preferences = $2, updated_at = $3│ +│ │ +│ Uses: database.Pool.DB (*sqlx.DB) │ +└────────────────┬─────────────────────────────────────────────────┘ + │ +┌────────────────▼─────────────────────────────────────────────────┐ +│ Domain Layer (Pure) │ +│ │ +│ UserPreferences, Preferences, NotificationPreferences │ +│ DefaultPreferences() │ +│ Validate() → error │ +│ ErrInvalidTheme, ErrInvalidLanguage, ErrForbidden │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Layer-by-Layer Implementation Details + +### Domain (`internal/domain/`) + +**Files to create:** +- `preferences.go` — Types, constructors, `DefaultPreferences()`, `Validate()` +- `errors.go` — Updated with `ErrInvalidTheme`, `ErrInvalidLanguage`, `ErrForbidden`, `ErrPreferencesNotFound` + +**Files to delete:** +- `example.go` + +**Validation logic in `Preferences.Validate()`:** +```go +func (p *Preferences) Validate() error { + if p.Theme != "" { + switch p.Theme { + case "light", "dark", "system": + // valid + default: + return ErrInvalidTheme + } + } + if len([]rune(p.Language)) > 10 { + return ErrInvalidLanguage + } + return nil +} +``` + +Unknown keys: The spec says unknown keys are preserved but not validated. Since we store the full JSON document in a JSONB column, unknown keys survive naturally. The `Preferences` struct uses a map for extensibility: + +```go +type Preferences struct { + Theme string `json:"theme"` + Language string `json:"language"` + Notifications NotificationPreferences `json:"notifications"` + Extra map[string]any `json:"-"` // captured via custom marshal/unmarshal +} +``` + +A custom `UnmarshalJSON`/`MarshalJSON` pair on `Preferences` decodes known fields into struct fields and captures everything else into `Extra`. On marshal, known fields and `Extra` are merged back. This preserves unknown keys through the round-trip without schema migrations. + +### Port (`internal/port/`) + +**Files to create:** +- `preferences.go` — `PreferencesRepository` interface + +**Files to delete:** +- `example.go` + +```go +type PreferencesRepository interface { + Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) + Upsert(ctx context.Context, prefs *domain.UserPreferences) error +} +``` + +Only two methods needed — no List, Delete, or ExistsByName. The simple interface keeps the adapter thin. + +### Service (`internal/service/`) + +**Files to create:** +- `preferences.go` — `PreferencesService` +- `preferences_test.go` — Unit tests + +**Files to delete:** +- `example.go` +- `example_test.go` + +```go +type PreferencesService struct { + repo port.PreferencesRepository + logger *logging.Logger +} + +func (s *PreferencesService) GetPreferences(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) +func (s *PreferencesService) SetPreferences(ctx context.Context, userID domain.UserID, prefs domain.Preferences) (*domain.UserPreferences, error) +``` + +**GetPreferences logic:** +1. Call `repo.Get(ctx, userID)` +2. If `ErrPreferencesNotFound`, return `DefaultPreferences()` with the given `userID` +3. Otherwise return the stored preferences + +**SetPreferences logic:** +1. Call `prefs.Validate()` — return domain error if invalid +2. Build `UserPreferences{UserID: userID, Preferences: prefs, UpdatedAt: time.Now().UTC()}` +3. Call `repo.Upsert(ctx, &userPrefs)` +4. Return the saved preferences + +Authorization (checking user_id matches authenticated user) is done in the **handler layer**, not here — the service layer doesn't know about HTTP or JWT. This follows the existing pattern where `mapDomainError()` in handlers maps domain errors to HTTP errors. + +### Adapter (`internal/adapter/postgres/`) + +**Files to create:** +- `preferences.go` — PostgreSQL implementation of `PreferencesRepository` + +**Files to delete:** +- `adapter/memory/example.go` + +```go +type PreferencesRepository struct { + db *sqlx.DB + logger *logging.Logger +} + +func (r *PreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) { + // SELECT user_id, preferences, updated_at FROM user_preferences WHERE user_id = $1 + // If no rows: return domain.ErrPreferencesNotFound + // Unmarshal JSONB into domain.Preferences +} + +func (r *PreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error { + // INSERT INTO user_preferences (user_id, preferences, updated_at) + // VALUES ($1, $2, $3) + // ON CONFLICT (user_id) DO UPDATE SET preferences = $2, updated_at = $3 + // Marshal domain.Preferences to JSON for JSONB column +} +``` + +### Handlers (`internal/api/handlers/`) + +**Files to create:** +- `preferences.go` — GET and PUT handlers +- `preferences_test.go` — Handler tests + +**Files to delete:** +- `example.go` +- `example_test.go` + +```go +type Preferences struct { + svc *service.PreferencesService + logger *logging.Logger +} +``` + +**Request/Response types:** + +```go +type PutPreferencesRequest struct { + Preferences PreferencesPayload `json:"preferences" validate:"required"` +} + +type PreferencesPayload struct { + Theme string `json:"theme,omitempty"` + Language string `json:"language,omitempty"` + Notifications *NotificationPreferencesPayload `json:"notifications,omitempty"` +} + +type NotificationPreferencesPayload struct { + Email bool `json:"email"` + Push bool `json:"push"` + SMS bool `json:"sms"` +} + +type PreferencesResponse struct { + UserID string `json:"user_id"` + Preferences PreferencesPayload `json:"preferences"` + UpdatedAt string `json:"updated_at"` +} +``` + +**Handler: Get** +```go +func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error { + userID := chi.URLParam(r, "user_id") + + // Authorization check + authUser := auth.GetUser(r.Context()) + if authUser.ID != userID { + return httperror.Forbidden("access denied: can only access own preferences") + } + + prefs, err := h.svc.GetPreferences(r.Context(), domain.UserID(userID)) + if err != nil { + return mapDomainError(err) + } + + return httpresponse.OK(w, r, toResponse(prefs)) +} +``` + +**Handler: Put** +```go +func (h *Preferences) Put(w http.ResponseWriter, r *http.Request) error { + userID := chi.URLParam(r, "user_id") + + // Authorization check + authUser := auth.GetUser(r.Context()) + if authUser.ID != userID { + return httperror.Forbidden("access denied: can only modify own preferences") + } + + var req PutPreferencesRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + prefs, err := h.svc.SetPreferences(r.Context(), domain.UserID(userID), toDomain(req.Preferences)) + if err != nil { + return mapDomainError(err) + } + + return httpresponse.OK(w, r, toResponse(prefs)) +} +``` + +**Error mapping:** +```go +func mapDomainError(err error) error { + switch { + case errors.Is(err, domain.ErrInvalidTheme): + return httperror.BadRequest("invalid theme: must be one of light, dark, system") + case errors.Is(err, domain.ErrInvalidLanguage): + return httperror.BadRequest("invalid language: must be at most 10 characters") + default: + return err // app.Wrap() will handle as 500 + } +} +``` + +### Routes (`internal/api/routes.go`) + +Replace example routes with: + +```go +func RegisterRoutes(application *app.App, prefsSvc *service.PreferencesService, authCfg config.Config) { + logger := application.Logger() + + healthHandler := &handlers.Health{Logger: logger} + prefsHandler := handlers.NewPreferences(prefsSvc, logger) + + r := application.Router() + + // Public routes + r.Get("/api/preferences-api/health", healthHandler.Check) + + // Protected routes — auth required for all preference endpoints + r.Route("/api/preferences-api/preferences", func(r chi.Router) { + if authCfg.AuthEnabled { + r.Use(auth.Middleware(auth.MiddlewareConfig{ + Validator: auth.NewJWTValidator(auth.JWTConfig{ + Secret: []byte(authCfg.JWTSecret), + }), + })) + } + r.Get("/{user_id}", app.Wrap(prefsHandler.Get)) + r.Put("/{user_id}", app.Wrap(prefsHandler.Put)) + }) +} +``` + +### OpenAPI Spec (`internal/api/spec.go`) + +Update to define: +- Schema: `Preferences` (theme, language, notifications) +- Schema: `NotificationPreferences` (email, push, sms) +- Schema: `UserPreferencesResponse` (user_id, preferences, updated_at) +- Schema: `PutPreferencesRequest` (preferences object) +- Path: `GET /api/preferences-api/preferences/{user_id}` with bearer auth, 200/403 responses +- Path: `PUT /api/preferences-api/preferences/{user_id}` with bearer auth, 200/400/403 responses +- `{user_id}` path parameter + +### main.go (`cmd/server/main.go`) + +Update to: +1. Connect to PostgreSQL using `database.Connect()` +2. Run migrations using `database.MustRunMigrations()` +3. Create `postgres.PreferencesRepository` with `pool.DB` +4. Create `PreferencesService` with the repo +5. Register routes with service and auth config +6. Register `pool.Close()` on shutdown + +## Error Handling Strategy + +| Scenario | Layer | Error | HTTP Status | +|----------|-------|-------|-------------| +| Invalid theme value | Domain | `ErrInvalidTheme` | 400 Bad Request | +| Language too long | Domain | `ErrInvalidLanguage` | 400 Bad Request | +| Malformed JSON body | Handler (BindAndValidate) | Automatic | 400 Bad Request | +| Missing `preferences` field | Handler (BindAndValidate) | Validation | 400 Bad Request | +| User accessing another user's prefs | Handler | `httperror.Forbidden` | 403 Forbidden | +| No preferences saved yet | Service | (returns defaults) | 200 OK | +| Database connection failure | Adapter | raw error | 500 Internal | +| Database query failure | Adapter | raw error | 500 Internal | + +**Key decisions:** +- GET never returns 404 — missing preferences yield defaults. This simplifies the frontend (no special "first time" flow). +- Authorization is checked in handlers before any service call, failing fast with 403. +- Domain validation errors are specific and mapped to descriptive 400 messages. +- Database errors bubble up as raw errors, caught by `app.Wrap()` and returned as 500 with the error logged server-side (not leaked to client). + +## Security Considerations + +### Authentication +- Both endpoints require `auth.Middleware()`. Unauthenticated requests receive 401. +- JWT validation via `pkg/auth.NewJWTValidator` with HMAC secret from config. + +### Authorization +- **Owner-only access**: The `{user_id}` in the URL path must match `auth.GetUser(ctx).ID`. This is checked in the handler before calling the service layer. +- No admin override endpoint (out of scope per spec). + +### Input Validation +- Request body bound and validated via `app.BindAndValidate()` — rejects malformed JSON and missing required fields. +- Domain-level validation for `theme` (enum) and `language` (max length). +- JSONB column stores raw preferences — unknown keys preserved but size is bounded by PostgreSQL's TOAST limit (~1GB). For practical limits, the handler can check `Content-Length` against a reasonable threshold (e.g., 64KB). This addresses the spec's open question about preference size limits. + +### Data Boundaries +- Users can only read/write their own preferences — no cross-user data access. +- Error responses never leak internal details (database errors, stack traces). +- The `preferences` JSONB column is treated as opaque by the database — no SQL injection vector. + +### SQL Injection +- All queries use parameterized statements (`$1`, `$2`) via `sqlx` — no string concatenation. + +## Performance Considerations + +### Expected Load +- Read-heavy workload: preferences fetched on every page load / session start. +- Writes are infrequent: users change preferences rarely. + +### Query Performance +- **GET**: Single-row lookup by primary key (`user_id`) — O(1) with B-tree index. +- **PUT**: Upsert by primary key — O(1). +- No need for additional indexes. The primary key index is sufficient. + +### Caching Strategy +- **Not implemented in this iteration** (out of scope). The single-row PK lookup is fast enough. +- If needed later: HTTP `Cache-Control` headers or an in-process cache with short TTL. + +### Connection Pooling +- Uses `database.Pool` with configurable pool size (default: 25 open, 5 idle). Adequate for preferences traffic. + +### Payload Size +- Preferences JSON is small (< 1KB typical). No pagination or streaming needed. + +## Migration / Rollout Plan + +### Step 1: Database Migration +The `001_create_user_preferences.sql` migration runs on startup via `database.MustRunMigrations()`. It uses `CREATE TABLE IF NOT EXISTS` for idempotency. No existing tables are modified or dropped. + +### Step 2: Code Deployment +The service is fully backward-compatible at the infrastructure level: +- Same port (8001) +- Same health check path (`/api/preferences-api/health`) +- Example endpoints are removed, but nothing depends on them (they're scaffold) + +### Step 3: Verification +- Health check confirms service starts and database is reachable +- GET returns default preferences for any authenticated user (no data seeding needed) +- PUT creates preferences on first save + +### Rollback +- Revert to previous deployment. The `user_preferences` table can remain — it won't interfere with the example scaffold code. +- No destructive migrations — forward-only table creation. + +## Open Questions Resolution + +From the spec: + +1. **Authorization model**: Design uses `auth.GetUser(ctx).ID` — the `User.ID` field populated by JWT validation. This maps to the `sub` claim or `uid` custom claim (both supported by `pkg/auth.JWTClaims`). No changes to auth package needed. + +2. **Unknown preference keys**: Preserved via custom JSON marshaling on the `Preferences` struct. Known keys are validated; unknown keys pass through to JSONB storage unchanged. + +3. **Preference size limit**: Addressed by checking request `Content-Length` in the handler (64KB default). This prevents abuse without requiring schema changes. + +## File Change Summary + +| Action | File | +|--------|------| +| **Create** | `migrations/001_create_user_preferences.sql` | +| **Create** | `internal/domain/preferences.go` | +| **Create** | `internal/port/preferences.go` | +| **Create** | `internal/service/preferences.go` | +| **Create** | `internal/service/preferences_test.go` | +| **Create** | `internal/adapter/postgres/preferences.go` | +| **Create** | `internal/api/handlers/preferences.go` | +| **Create** | `internal/api/handlers/preferences_test.go` | +| **Modify** | `internal/domain/errors.go` | +| **Modify** | `internal/api/routes.go` | +| **Modify** | `internal/api/spec.go` | +| **Modify** | `cmd/server/main.go` | +| **Delete** | `internal/domain/example.go` | +| **Delete** | `internal/port/example.go` | +| **Delete** | `internal/service/example.go` | +| **Delete** | `internal/service/example_test.go` | +| **Delete** | `internal/adapter/memory/example.go` | +| **Delete** | `internal/api/handlers/example.go` | +| **Delete** | `internal/api/handlers/example_test.go` | diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 4cf2549..b9c2161 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -10,7 +10,7 @@ artifacts: status: pending path: audit.md design: - status: pending + status: draft path: design.md qa_plan: status: pending