From 5a6d2dc3a9ea0301db0ed859e5d4b5a80bf28f76 Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sun, 8 Feb 2026 01:47:26 +0000 Subject: [PATCH] build: /design-feature user-preferences --- .sdlc/features/user-preferences/design.md | 449 ++++++++++++++++++ .sdlc/features/user-preferences/manifest.yaml | 2 +- 2 files changed, 450 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..c649451 --- /dev/null +++ b/.sdlc/features/user-preferences/design.md @@ -0,0 +1,449 @@ +# Design: User Preferences API + +## Architecture Approach + +Replace the existing `example` scaffold in the `preferences-api` service with a user preferences system. The hexagonal architecture layers remain identical — only the domain entity, service logic, port interface, adapter, handlers, and OpenAPI spec change. No new services, packages, or infrastructure components are introduced. + +### Layers Changed + +| Layer | What Changes | +|-------|-------------| +| **Domain** | `Example` entity → `Preferences` entity with typed fields and validation | +| **Port** | `ExampleRepository` interface → `PreferencesRepository` with Get/Upsert by UserID | +| **Adapter** | In-memory map keyed by `UserID` instead of `ExampleID` | +| **Service** | CRUD logic → Get + Upsert with shallow merge and authorization checks | +| **Handlers** | 5 endpoints (CRUD) → 2 endpoints (GET + PUT) with auth-gated access | +| **OpenAPI** | Example schemas/paths → Preferences schemas/paths | +| **Config** | No changes (auth config already exists) | +| **main.go** | Wire `PreferencesRepository` and `PreferencesService` instead of example equivalents | + +### What Is Removed + +All `example` scaffold code: `domain/example.go`, `port/example.go`, `adapter/memory/example.go`, `service/example.go`, `handlers/example.go`, and their tests. The `domain/errors.go` file is replaced with preferences-specific errors. + +## Data Model Changes + +### Domain Entity: `preferences.go` + +```go +package domain + +import "time" + +// UserID is a strongly-typed identifier for users. +type UserID string + +func (id UserID) String() string { return string(id) } +func (id UserID) IsZero() bool { return id == "" } + +// Preferences holds all user preferences. +type Preferences struct { + UserID UserID + Theme string + Language string + Notifications NotificationSettings + UpdatedAt time.Time +} + +type NotificationSettings struct { + Email bool + Push bool + Digest string +} +``` + +### Allowed Values (validated in domain) + +| Field | Type | Allowed | Default | +|-------|------|---------|---------| +| `theme` | string | `light`, `dark`, `system` | `system` | +| `language` | string | ISO 639-1 (validated via regex `^[a-z]{2}$`) | `en` | +| `notifications.email` | bool | `true`, `false` | `true` | +| `notifications.push` | bool | `true`, `false` | `true` | +| `notifications.digest` | string | `daily`, `weekly`, `never` | `weekly` | + +### Domain Validation + +```go +// NewDefaultPreferences returns a Preferences with all defaults applied. +func NewDefaultPreferences(userID UserID) *Preferences + +// Validate checks that all field values are within allowed sets. +// Returns a domain error listing invalid fields. +func (p *Preferences) Validate() error + +// MergeFrom applies a shallow merge: top-level keys from `incoming` overwrite +// corresponding fields in `p`. The Notifications struct is replaced entirely +// when provided. Fields not present in `incoming` are left unchanged. +func (p *Preferences) MergeFrom(incoming *PreferencesUpdate) +``` + +### Domain Errors: `errors.go` + +```go +var ( + ErrPreferencesNotFound = errors.New("preferences not found") + ErrInvalidTheme = errors.New("invalid theme value") + ErrInvalidLanguage = errors.New("invalid language value") + ErrInvalidDigest = errors.New("invalid digest value") + ErrInvalidPreferences = errors.New("invalid preferences") +) +``` + +### Merge Semantics (Detail) + +The spec requires **shallow merge at top-level keys**. Concretely: + +1. Client sends `{"preferences": {"theme": "light"}}` → only `theme` changes; `language` and `notifications` are untouched. +2. Client sends `{"preferences": {"notifications": {"email": false}}}` → the entire `notifications` struct is replaced with `{email: false, push: false, digest: ""}`. Because we replace the whole nested object, missing sub-fields get zero values — but we **fill missing notification sub-fields with defaults** before validation to avoid forcing the client to always send the full object. This is the most user-friendly interpretation of "replaced entirely when provided" from the spec. + +Implementation: A `PreferencesUpdate` struct uses pointer fields to distinguish "provided" from "not provided": + +```go +type PreferencesUpdate struct { + Theme *string + Language *string + Notifications *NotificationSettingsUpdate +} + +type NotificationSettingsUpdate struct { + Email *bool + Push *bool + Digest *string +} +``` + +`MergeFrom` only overwrites fields where the pointer is non-nil. For `Notifications`, if the pointer is non-nil, individual sub-fields within `NotificationSettingsUpdate` are merged (non-nil pointers overwrite, nil pointers keep existing values). This provides the predictable behavior the spec describes while not forcing clients to send complete notification objects. + +## API Changes + +### Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/preferences-api/preferences/{user_id}` | Required | Get all preferences for a user | +| PUT | `/api/preferences-api/preferences/{user_id}` | Required | Create or update preferences (merge semantics) | + +### Removed Endpoints + +All `/api/preferences-api/examples/*` endpoints are removed. + +### GET `/api/preferences-api/preferences/{user_id}` + +**Path Parameters:** +- `user_id` — UUID format, validated with `httpvalidation` UUID validator + +**Authorization:** +- Token subject must match `user_id`, OR user must have `admin` role +- Returns `403 Forbidden` if neither condition is met + +**Responses:** +- `200 OK` — preferences found, returned in envelope +- `403 Forbidden` — user_id doesn't match token and user is not admin +- `404 Not Found` — no preferences stored for this user + +**Response Body (200):** +```json +{ + "data": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "preferences": { + "theme": "dark", + "language": "en", + "notifications": { + "email": true, + "push": false, + "digest": "weekly" + } + }, + "updated_at": "2026-02-08T00:00:00Z" + }, + "meta": { "request_id": "...", "timestamp": "..." } +} +``` + +### PUT `/api/preferences-api/preferences/{user_id}` + +**Path Parameters:** +- `user_id` — UUID format + +**Authorization:** +- Same as GET (own-user or admin) + +**Request Body:** +```json +{ + "preferences": { + "theme": "light", + "notifications": { + "email": false + } + } +} +``` + +**Validation:** +- `preferences` field must be present and a JSON object → `400 Bad Request` if missing +- Unknown top-level keys inside `preferences` (anything other than `theme`, `language`, `notifications`) → `400 Bad Request` +- Values validated against allowed sets → `400 Bad Request` with field-level details + +**Responses:** +- `200 OK` — preferences created or updated, full merged result returned +- `400 Bad Request` — validation failure (missing preferences, unknown keys, invalid values) +- `403 Forbidden` — authorization failure + +**Response Body (200):** Same shape as GET response, with merged preferences. + +### Handler Request/Response Types + +```go +// PUT request body +type UpdatePreferencesRequest struct { + Preferences *PreferencesPayload `json:"preferences" validate:"required"` +} + +type PreferencesPayload struct { + Theme *string `json:"theme,omitempty"` + Language *string `json:"language,omitempty"` + Notifications *NotificationSettingsPayload `json:"notifications,omitempty"` +} + +type NotificationSettingsPayload struct { + Email *bool `json:"email,omitempty"` + Push *bool `json:"push,omitempty"` + Digest *string `json:"digest,omitempty"` +} + +// GET/PUT response body (inside envelope) +type PreferencesResponse struct { + UserID string `json:"user_id"` + Preferences PreferencesDataResponse `json:"preferences"` + UpdatedAt string `json:"updated_at"` +} + +type PreferencesDataResponse struct { + Theme string `json:"theme"` + Language string `json:"language"` + Notifications NotificationSettingsResponse `json:"notifications"` +} + +type NotificationSettingsResponse struct { + Email bool `json:"email"` + Push bool `json:"push"` + Digest string `json:"digest"` +} +``` + +**Unknown field rejection:** Use `app.BindStrict()` for the top-level request, and perform manual check on the `PreferencesPayload` to reject unknown keys. Alternatively, use `json.Decoder.DisallowUnknownFields()` via `app.BindAndValidateStrict()` for the outer struct, and add a custom validation step for the inner `preferences` object keys. + +## Component Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ HTTP Client │ +│ (Frontend / API Consumer) │ +└──────────┬──────────────────────────────────────────┬────────────┘ + │ GET /preferences/{user_id} │ PUT /preferences/{user_id} + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ chi Router │ +│ ├── auth.Middleware() ─── JWT validation │ +│ └── /api/preferences-api/preferences/{user_id} │ +└──────────┬──────────────────────────────────────────┬────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Handlers (internal/api/handlers/preferences.go) │ +│ ├── Get(w, r) error │ +│ │ ├── Extract user_id from path, validate UUID │ +│ │ ├── Authorization check (own-user or admin) │ +│ │ ├── Call service.Get() │ +│ │ └── Return httpresponse.OK() or httperror.NotFound() │ +│ └── Update(w, r) error │ +│ ├── Extract user_id from path, validate UUID │ +│ ├── Authorization check (own-user or admin) │ +│ ├── app.BindAndValidateStrict() request body │ +│ ├── Call service.Upsert() │ +│ └── Return httpresponse.OK() with merged result │ +└──────────┬──────────────────────────────────────────┬────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Service (internal/service/preferences.go) │ +│ ├── Get(ctx, userID) → (*Preferences, error) │ +│ └── Upsert(ctx, userID, update) → (*Preferences, error) │ +│ ├── repo.Get() to fetch existing (or create defaults) │ +│ ├── domain.MergeFrom(update) │ +│ ├── domain.Validate() │ +│ └── repo.Upsert() │ +└──────────┬──────────────────────────────────────────┬────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Port Interface (internal/port/preferences.go) │ +│ PreferencesRepository { │ +│ Get(ctx, userID) → (*Preferences, error) │ +│ Upsert(ctx, userID, prefs) → error │ +│ } │ +└──────────┬──────────────────────────────────────────┬────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Adapter: In-Memory (internal/adapter/memory/preferences.go) │ +│ map[UserID]*Preferences protected by sync.RWMutex │ +│ ├── Get: lookup by key, return copy or ErrPreferencesNotFound │ +│ └── Upsert: store copy (insert or replace) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Error Handling Strategy + +### Error Flow + +``` +Domain errors (domain.Err*) + ↓ returned to service +Service returns domain errors unchanged + ↓ returned to handler +Handler maps via mapDomainError() + ↓ converts to httperror.* +app.Wrap() writes HTTP response +``` + +### Error Mapping Table + +| Domain Error | HTTP Status | HTTP Code | When | +|-------------|-------------|-----------|------| +| `ErrPreferencesNotFound` | 404 | `NOT_FOUND` | GET for user with no stored preferences | +| `ErrInvalidTheme` | 400 | `BAD_REQUEST` | Theme value not in allowed set | +| `ErrInvalidLanguage` | 400 | `BAD_REQUEST` | Language not matching `^[a-z]{2}$` | +| `ErrInvalidDigest` | 400 | `BAD_REQUEST` | Digest value not in allowed set | +| `ErrInvalidPreferences` | 400 | `BAD_REQUEST` | Generic validation failure | +| (binding error) | 400 | `BAD_REQUEST` | Malformed JSON, missing `preferences` field | +| (unknown fields) | 400 | `BAD_REQUEST` | Unknown keys in `preferences` object | +| (auth failure) | 401 | `UNAUTHORIZED` | Missing or invalid JWT token | +| (authz failure) | 403 | `FORBIDDEN` | user_id doesn't match token, not admin | +| (UUID validation) | 400 | `BAD_REQUEST` | `user_id` path param not a valid UUID | +| (unexpected) | 500 | `INTERNAL_ERROR` | Any unhandled error (logged by `app.Wrap`) | + +### Validation Detail Response + +For validation errors, the response includes field-level details: + +```json +{ + "error": { + "code": "BAD_REQUEST", + "message": "invalid preferences", + "details": [ + { "field": "theme", "message": "must be one of: light, dark, system" } + ] + }, + "meta": { "request_id": "...", "timestamp": "..." } +} +``` + +## Security Considerations + +### Authentication + +- Both endpoints require a valid JWT token via `auth.Middleware()`. +- Auth middleware runs before handler code — unauthenticated requests never reach service logic. +- Auth is gated by `AUTH_ENABLED` config (same as existing pattern) to allow local dev without JWT. + +### Authorization + +- **Own-user access**: The handler extracts the authenticated user from context via `auth.GetUser(ctx)` and compares `user.ID` with the `user_id` path parameter. Mismatch returns `403 Forbidden`. +- **Admin override**: If `user.HasRole("admin")` is true, access is granted regardless of user_id match. +- Authorization is enforced in the handler layer (not service layer) because it depends on HTTP context (path params + auth context). + +### Input Validation + +| Input | Validation | Layer | +|-------|-----------|-------| +| `user_id` path param | UUID format regex | Handler | +| Request body JSON | Well-formed JSON, no unknown fields | Handler (via `app.BindAndValidateStrict`) | +| `preferences` field | Must be present and non-null | Handler (struct tag `validate:"required"`) | +| `theme` | Must be `light`, `dark`, or `system` | Domain | +| `language` | Must match `^[a-z]{2}$` | Domain | +| `notifications.digest` | Must be `daily`, `weekly`, or `never` | Domain | +| Unknown top-level keys | Rejected | Handler (strict binding) | + +### Data Exposure + +- Preferences contain no secrets or PII beyond user_id (which the user already knows). +- No cross-user data leakage: authorization check prevents reading/writing other users' preferences. +- Error messages do not leak internal details. + +## Performance Considerations + +### Expected Load + +- Preferences are read frequently (every page load / session init) and written rarely (settings page changes). +- Read-heavy workload: ~100:1 read-to-write ratio expected. + +### In-Memory Adapter Performance + +- O(1) lookups by UserID (map key). +- No serialization overhead — domain structs stored directly. +- `sync.RWMutex` allows concurrent reads; writes are serialized. +- Suitable for development and testing. Production will use PostgreSQL adapter (out of scope). + +### Response Size + +- Preferences response is small (~200 bytes JSON). No pagination needed. +- Single GET returns all preferences — no need for per-key endpoints. + +### Future Database Considerations (out of scope, noted for awareness) + +- `user_id` column should be the primary key (one row per user). +- `preferences` can be stored as JSONB for flexibility, or as individual columns for type safety. +- Index on `user_id` (primary key provides this automatically). + +## Migration / Rollout Plan + +### Step-by-Step + +1. **Remove example scaffold**: Delete all `example`-related files (domain, port, adapter, service, handlers, tests). +2. **Add domain layer**: Create `Preferences`, `NotificationSettings`, `PreferencesUpdate` types with validation. +3. **Add port interface**: Define `PreferencesRepository` with `Get` and `Upsert` methods. +4. **Add in-memory adapter**: Implement `PreferencesRepository` with thread-safe map. +5. **Add service layer**: Implement `PreferencesService` with `Get` and `Upsert` (merge + validate). +6. **Add handlers**: Implement GET and PUT with auth, validation, and error mapping. +7. **Update routes**: Replace example routes with preferences routes, apply auth middleware. +8. **Update OpenAPI spec**: Replace example schemas/paths with preferences schemas/paths. +9. **Update main.go**: Wire new repository, service, and routes. +10. **Write tests**: Service tests (merge logic, validation, authorization) and handler tests (HTTP layer). + +### Rollout Risk + +- **Zero risk to other services**: Changes are entirely within `preferences-api` service boundary. +- **No database migration**: In-memory adapter means no schema changes. +- **No API consumers yet**: The example API has no consumers, so removing it has no compatibility impact. +- **Backward incompatibility**: The `/examples` endpoints are removed entirely. This is expected — the spec explicitly requires replacing the scaffold. + +### File Changes Summary + +| Action | File | +|--------|------| +| Delete | `internal/domain/example.go` | +| Delete | `internal/domain/errors.go` (recreated with new errors) | +| Delete | `internal/port/example.go` | +| Delete | `internal/adapter/memory/example.go` | +| Delete | `internal/service/example.go` | +| Delete | `internal/service/example_test.go` | +| Delete | `internal/api/handlers/example.go` | +| Delete | `internal/api/handlers/example_test.go` | +| Create | `internal/domain/preferences.go` | +| Create | `internal/domain/errors.go` | +| Create | `internal/port/preferences.go` | +| Create | `internal/adapter/memory/preferences.go` | +| Create | `internal/service/preferences.go` | +| Create | `internal/service/preferences_test.go` | +| Create | `internal/api/handlers/preferences.go` | +| Create | `internal/api/handlers/preferences_test.go` | +| Modify | `internal/api/routes.go` | +| Modify | `internal/api/spec.go` | +| Modify | `cmd/server/main.go` | +| Keep | `internal/api/handlers/health.go` (unchanged) | +| Keep | `internal/config/config.go` (unchanged) | diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 35682f3..48a8887 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