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

This commit is contained in:
rdev-worker 2026-02-08 10:00:11 +00:00
parent 3624cb8e6d
commit 96af8d3c07
2 changed files with 401 additions and 1 deletions

View File

@ -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: <key>"` |
| `ErrInvalidPreferenceValue` | 400 Bad Request | `"invalid value for <key>: <reason>"` |
| 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.

View File

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