From 96af8d3c07f0e2300fc2a70742c1418337146929 Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sun, 8 Feb 2026 10:00:11 +0000 Subject: [PATCH] build: /design-feature user-preferences --- .sdlc/features/user-preferences/design.md | 400 ++++++++++++++++++ .sdlc/features/user-preferences/manifest.yaml | 2 +- 2 files changed, 401 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..11efd55 --- /dev/null +++ b/.sdlc/features/user-preferences/design.md @@ -0,0 +1,400 @@ +# Design: User Preferences API + +## Architecture Approach + +This feature replaces the scaffold Example entity in `preferences-api` with a real User Preferences domain. The implementation follows the existing hexagonal architecture pattern exactly: + +- **Domain layer**: New `UserPreferences` entity with validation for known preference keys and values +- **Port layer**: `PreferencesRepository` interface for persistence +- **Adapter layer**: PostgreSQL repository implementation using `pkg/database` (replaces in-memory) +- **Service layer**: `PreferencesService` with Get and Upsert operations, authorization checks +- **Handler layer**: GET and PUT handlers with request binding, error mapping, auth enforcement +- **Migration**: Single SQL migration to create `user_preferences` table with JSONB column + +No new patterns are introduced. Every layer follows the conventions established by the Example scaffold, with the scaffold code removed and replaced. + +### What Changes + +| Layer | Action | Files | +|-------|--------|-------| +| Domain | Replace `example.go`, `errors.go` | `internal/domain/preferences.go`, `internal/domain/errors.go` | +| Port | Replace `example.go` | `internal/port/preferences.go` | +| Adapter | Replace `adapter/memory/` with `adapter/postgres/` | `internal/adapter/postgres/preferences.go` | +| Service | Replace `example.go` | `internal/service/preferences.go`, `internal/service/preferences_test.go` | +| Handlers | Replace `example.go` | `internal/api/handlers/preferences.go`, `internal/api/handlers/preferences_test.go` | +| Routes | Update route registration | `internal/api/routes.go` | +| Spec | Update OpenAPI spec | `internal/api/spec.go` | +| Config | Already has `DatabaseConfig` — no changes needed | `internal/config/config.go` | +| Main | Add DB connection, migrations, wire postgres adapter | `cmd/server/main.go` | +| Migration | New file | `migrations/001_create_user_preferences.sql` | + +### What Gets Removed + +All Example scaffold files: `domain/example.go`, `port/example.go`, `adapter/memory/example.go`, `service/example.go`, `service/example_test.go`, `handlers/example.go`, `handlers/example_test.go`. The health handler remains unchanged. + +## Data Model Changes + +### Database Schema + +```sql +-- migrations/001_create_user_preferences.sql +CREATE TABLE user_preferences ( + user_id UUID PRIMARY KEY, + preferences JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +Design rationale: +- **JSONB column** stores preferences as a flexible key-value map while the domain layer enforces the allowed key set. This avoids schema changes when new preference keys are added in the future. +- **`user_id` as primary key** — one row per user, no surrogate ID needed. +- **No foreign key to a users table** — the preferences-api service does not own the users table. User identity comes from the JWT. + +### Domain Types + +```go +// internal/domain/preferences.go + +type UserPreferences struct { + UserID string + Preferences map[string]any + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +**Allowed preference keys and validation rules** (enforced in domain layer): + +| Key | Type | Valid Values | +|-----|------|-------------| +| `theme` | string | `"light"`, `"dark"` | +| `language` | string | ISO 639-1 pattern: 2 lowercase letters (e.g., `en`, `es`, `fr`) | +| `notifications_enabled` | bool | `true`, `false` | + +Domain validation functions: +- `ValidatePreferences(prefs map[string]any) error` — rejects unknown keys and invalid values +- `ValidatePreferenceKey(key string) error` — checks key is in the allowed set +- `ValidatePreferenceValue(key string, value any) error` — checks value is valid for the given key + +## API Changes + +### GET /api/preferences-api/preferences/{user_id} + +Retrieves all preferences for a user. Returns empty preferences (not 404) if the user has no saved preferences. + +**Auth**: Required (Bearer JWT). User ID from JWT must match `{user_id}` path parameter. + +**Response 200** (preferences exist): +```json +{ + "data": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "preferences": { + "theme": "dark", + "language": "en", + "notifications_enabled": true + }, + "updated_at": "2026-02-08T12:00:00Z" + }, + "meta": { + "request_id": "...", + "timestamp": "..." + } +} +``` + +**Response 200** (no preferences saved): +```json +{ + "data": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "preferences": {}, + "updated_at": null + }, + "meta": { ... } +} +``` + +**Error responses**: 400 (invalid UUID), 401 (unauthenticated), 403 (user ID mismatch). + +### PUT /api/preferences-api/preferences/{user_id} + +Creates or updates preferences with upsert semantics. Only provided keys are changed; omitted keys are preserved (merge behavior). + +**Auth**: Required. User ID from JWT must match `{user_id}`. + +**Request**: +```json +{ + "preferences": { + "theme": "dark", + "notifications_enabled": false + } +} +``` + +**Response 200** (returns full merged preferences): +```json +{ + "data": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "preferences": { + "theme": "dark", + "language": "en", + "notifications_enabled": false + }, + "updated_at": "2026-02-08T12:00:05Z" + }, + "meta": { ... } +} +``` + +**Error responses**: 400 (invalid UUID, unknown key, invalid value), 401 (unauthenticated), 403 (user ID mismatch). + +### Request/Response DTOs + +```go +// Handler-level DTOs +type UpdatePreferencesRequest struct { + Preferences map[string]any `json:"preferences" validate:"required"` +} + +type PreferencesResponse struct { + UserID string `json:"user_id"` + Preferences map[string]any `json:"preferences"` + UpdatedAt *time.Time `json:"updated_at"` +} +``` + +## Component Diagram + +``` +┌──────────────────────────────────────────────────────────┐ +│ HTTP Client │ +└────────────┬──────────────────────────────┬──────────────┘ + │ GET /preferences/{user_id} │ PUT /preferences/{user_id} + ▼ ▼ +┌──────────────────────────────────────────────────────────┐ +│ chi Router (/api/preferences-api) │ +│ ├── middleware.RequestID │ +│ ├── middleware.Tracing │ +│ ├── middleware.RequestLogger │ +│ ├── middleware.Recoverer │ +│ └── auth.Middleware (JWT) ◄── all pref routes │ +└────────────┬──────────────────────────────┬──────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────────────────────────────────┐ +│ handlers.Preferences │ +│ ├── Get(w, r) error │ +│ │ ├── chi.URLParam → user_id │ +│ │ ├── auth ownership check │ +│ │ └── httpresponse.OK(data) │ +│ └── Update(w, r) error │ +│ ├── chi.URLParam → user_id │ +│ ├── app.BindAndValidate → UpdatePreferencesRequest │ +│ ├── auth ownership check │ +│ └── httpresponse.OK(data) │ +└────────────┬──────────────────────────────┬──────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────────────────────────────────┐ +│ service.PreferencesService │ +│ ├── Get(ctx, userID) → (*UserPreferences, error) │ +│ └── Update(ctx, userID, prefs) → (*UserPreferences, err)│ +│ ├── domain.ValidatePreferences(prefs) │ +│ └── repo.Upsert(ctx, userID, prefs) │ +└────────────┬──────────────────────────────┬──────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────────────────────────────────┐ +│ port.PreferencesRepository (interface) │ +│ ├── Get(ctx, userID) → (*UserPreferences, error) │ +│ └── Upsert(ctx, userID, prefs) → (*UserPreferences, err)│ +└────────────┬──────────────────────────────┬──────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────────────────────────────────┐ +│ adapter/postgres.PreferencesRepository │ +│ ├── Get: SELECT ... WHERE user_id = $1 │ +│ └── Upsert: INSERT ... ON CONFLICT (user_id) │ +│ DO UPDATE SET preferences = merged, │ +│ updated_at = NOW() │ +└──────────────────────────────┬───────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ PostgreSQL │ + │ user_preferences │ + └─────────────────────┘ +``` + +## Error Handling Strategy + +### Domain Errors + +```go +var ( + ErrInvalidPreferenceKey = errors.New("invalid preference key") + ErrInvalidPreferenceValue = errors.New("invalid preference value") +) +``` + +### Handler Error Mapping + +| Domain Error | HTTP Status | Response | +|-------------|------------|----------| +| `ErrInvalidPreferenceKey` | 400 Bad Request | `"unknown preference key: "` | +| `ErrInvalidPreferenceValue` | 400 Bad Request | `"invalid value for : "` | +| Unauthenticated request | 401 Unauthorized | Handled by `auth.Middleware` | +| User ID mismatch | 403 Forbidden | `"access denied"` | +| Invalid UUID in path | 400 Bad Request | `"invalid user ID format"` | +| Missing `preferences` field | 400 Bad Request | Handled by `app.BindAndValidate` | +| Unhandled / DB error | 500 Internal | Logged; generic message to client via `app.Wrap` | + +### Error Mapping Function + +```go +func mapDomainError(err error) error { + switch { + case errors.Is(err, domain.ErrInvalidPreferenceKey): + return httperror.BadRequest(err.Error()) + case errors.Is(err, domain.ErrInvalidPreferenceValue): + return httperror.BadRequest(err.Error()) + default: + return err // becomes 500 via app.Wrap + } +} +``` + +### Database Failures + +- Connection errors during startup: `database.MustConnect` panics with descriptive message. +- Query errors at runtime: Bubble up through the adapter as raw errors, logged by middleware, returned as 500. +- Migration failures at startup: `database.MustRunMigrations` panics with descriptive message. + +## Security Considerations + +### Authentication + +All preference endpoints require authentication. Auth middleware is applied to the entire preferences route group (not selectively per-route like the scaffold): + +```go +r.Group(func(r app.Router) { + if cfg.AuthEnabled { + r.Use(auth.Middleware(auth.MiddlewareConfig{ + Validator: auth.NewJWTValidator(auth.JWTConfig{ + Secret: []byte(cfg.JWTSecret), + Issuer: "slack5-1770544098", + }), + })) + } + r.Get("/preferences/{user_id}", app.Wrap(prefHandler.Get)) + r.Put("/preferences/{user_id}", app.Wrap(prefHandler.Update)) +}) +``` + +### Authorization (Ownership Check) + +Handlers enforce that the authenticated user can only access their own preferences: + +```go +func (h *Preferences) checkOwnership(r *http.Request, userID string) error { + user := auth.MustGetUser(r.Context()) + if user.ID != userID { + return httperror.Forbidden("access denied") + } + return nil +} +``` + +This is checked in both GET and PUT handlers before calling the service layer. + +### Input Validation + +1. **Path parameter**: UUID format validated via `uuid.Parse()`. +2. **Request body**: `app.BindAndValidate()` ensures `preferences` field is present. +3. **Preference keys**: Domain layer rejects any key not in `{theme, language, notifications_enabled}`. +4. **Preference values**: Domain layer validates per-key: + - `theme`: must be `"light"` or `"dark"` + - `language`: must match `^[a-z]{2}$` (ISO 639-1) + - `notifications_enabled`: must be a boolean +5. **JSONB injection**: PostgreSQL parameterized queries prevent SQL injection. Go's `encoding/json` handles JSON marshaling safely. + +### Data Boundaries + +- Users cannot read or write other users' preferences (403). +- The API does not expose internal database IDs or timestamps beyond `updated_at`. +- Error messages do not leak internal details (domain errors have descriptive but safe messages). + +## Performance Considerations + +### Expected Load + +User preferences are typically read on session start and written infrequently (settings changes). Expected pattern: **high read, low write**. + +### Query Performance + +- **GET**: Single-row lookup by primary key (`user_id UUID`). O(1) index lookup — no additional indexes needed. +- **PUT (Upsert)**: `INSERT ... ON CONFLICT` operates on the primary key — efficient single-row upsert. +- **No list/search endpoints**: No table scans or complex queries. + +### Caching Strategy + +Not needed for initial implementation. The query is a primary key lookup on a single small row. If needed later, HTTP-level caching (ETag/Last-Modified based on `updated_at`) or application-level caching can be added without architectural changes. + +### Data Size + +Each row contains a JSONB object with at most 3 keys. Row size is trivially small (~200 bytes). Even at millions of users, the table fits comfortably in PostgreSQL's buffer cache. + +## Migration / Rollout Plan + +### Step 1: Database Migration + +Create `migrations/001_create_user_preferences.sql`: + +```sql +CREATE TABLE IF NOT EXISTS user_preferences ( + user_id UUID PRIMARY KEY, + preferences JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +Migration runs automatically at service startup via `database.MustRunMigrations()`. The `IF NOT EXISTS` clause makes it idempotent. + +### Step 2: Remove Scaffold, Implement Feature + +All Example scaffold code is replaced with preferences code in a single feature branch. Since the scaffold has no production users, this is a clean swap with no backward compatibility concerns. + +### Step 3: Wire Database in Main + +Update `cmd/server/main.go`: +1. Read `DatabaseConfig` from config. +2. Connect to PostgreSQL via `database.MustConnect()`. +3. Run migrations via `database.MustRunMigrations()`. +4. Create `postgres.PreferencesRepository` with the DB pool. +5. Create `PreferencesService` with the postgres repository. +6. Register shutdown hook to close DB pool. + +### Step 4: Deploy + +Standard service deployment. The migration creates a new table with no dependencies on existing tables, so there is zero risk to existing data or services. + +### Rollback + +If issues arise, revert the deployment to the previous version. The `user_preferences` table can remain (empty or with minimal data) — it causes no harm. A future migration can drop it if the feature is permanently abandoned. + +## Open Questions Resolution + +From the spec's open questions, the design makes these decisions: + +1. **Language validation strictness**: Accept any valid ISO 639-1 pattern (`^[a-z]{2}$`). This is permissive enough to avoid maintaining a language list while still rejecting obviously invalid input. + +2. **Default preferences**: The API returns empty `{}` for users with no preferences. The frontend handles defaults. This keeps the API simple and avoids coupling to UI decisions. + +3. **Rate limiting**: Not implemented in this feature. Rate limiting is a cross-cutting concern best handled at the infrastructure level (API gateway/ingress) rather than per-service. + +4. **Removing the scaffold**: Yes — all Example scaffold code is removed and replaced with preferences code. The scaffold served its purpose as a template. diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 7c0b3e5..851f4d9 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