build: /design-feature user-preferences
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
rdev-worker 2026-02-08 01:47:26 +00:00
parent a167ae7c25
commit 5a6d2dc3a9
2 changed files with 450 additions and 1 deletions

View File

@ -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) |

View File

@ -10,7 +10,7 @@ artifacts:
status: pending
path: audit.md
design:
status: pending
status: draft
path: design.md
qa_plan:
status: pending