build: /design-feature user-preferences
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
a167ae7c25
commit
5a6d2dc3a9
449
.sdlc/features/user-preferences/design.md
Normal file
449
.sdlc/features/user-preferences/design.md
Normal 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) |
|
||||
@ -10,7 +10,7 @@ artifacts:
|
||||
status: pending
|
||||
path: audit.md
|
||||
design:
|
||||
status: pending
|
||||
status: draft
|
||||
path: design.md
|
||||
qa_plan:
|
||||
status: pending
|
||||
|
||||
Loading…
Reference in New Issue
Block a user