slate-test-1770505673/.sdlc/features/user-preferences/design.md
rdev-worker 37e6dbe519
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /design-feature user-preferences
2026-02-07 23:32:27 +00:00

413 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design: User Preferences API
## Architecture Approach
Replace the existing example CRUD scaffold in `services/preferences-api/` with a preference-specific domain following the same hexagonal architecture pattern: **domain → service → port (interface) → adapter (implementation)**.
### What Changes
| Layer | Action | Description |
|-------|--------|-------------|
| **Domain** | Replace | New `Preference` and `PreferenceKey` types with validation; remove `Example` entity |
| **Port** | Replace | New `PreferenceRepository` interface with `GetByUserID` and `Upsert` methods |
| **Adapter** | Replace | New PostgreSQL adapter (replaces in-memory); new migration for `user_preferences` table |
| **Service** | Replace | New `PreferenceService` with get/upsert business logic, key/value validation |
| **Handlers** | Replace | New `Preference` handler for GET and PUT endpoints with auth enforcement |
| **Routes** | Modify | Update route registration: both endpoints require auth, add user_id ownership check |
| **Spec** | Replace | New OpenAPI documentation for preference endpoints |
| **Main** | Modify | Wire PostgreSQL pool, run migrations, inject into preference service |
### What Stays the Same
- Service name (`preferences-api`), port (`8001`), base path (`/api/preferences-api`)
- Health check endpoint and handler
- Config loading pattern (extended with database config)
- All `pkg/*` dependencies remain unchanged
- Makefile, Dockerfile, component.yaml structure
## Data Model Changes
### Domain Types
```go
// internal/domain/preference.go
// AllowedKeys defines the valid preference keys and their allowed values.
var AllowedKeys = map[string][]string{
"theme": {"light", "dark", "system"},
"language": {}, // validated via regex: ^[a-z]{2}$ (ISO 639-1)
"notifications_enabled": {"true", "false"},
}
// Preference represents a single user preference key-value pair.
type Preference struct {
UserID string
Key string
Value string
}
// Validate checks that Key is known and Value is valid for that key.
func (p *Preference) Validate() error { ... }
// ValidateKey checks if a key is in the allowed set.
func ValidateKey(key string) error { ... }
// ValidateValue checks if a value is valid for the given key.
func ValidateValue(key, value string) error { ... }
```
```go
// internal/domain/errors.go
var (
ErrUnknownKey = errors.New("unknown preference key")
ErrInvalidValue = errors.New("invalid preference value")
ErrForbidden = errors.New("access denied")
)
```
### Database Schema
```sql
-- migrations/001_create_user_preferences.sql
CREATE TABLE IF NOT EXISTS user_preferences (
user_id UUID NOT NULL,
key VARCHAR(64) NOT NULL,
value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, key)
);
CREATE INDEX idx_user_preferences_user_id ON user_preferences (user_id);
```
**Design decisions:**
- **Composite primary key** `(user_id, key)` — enforces one value per key per user, enables efficient upsert via `ON CONFLICT`.
- **Key-value model** rather than a wide row — allows adding new preference keys without schema migration.
- **Index on `user_id`** — supports efficient retrieval of all preferences for a single user.
- **No foreign key to a users table** — the preferences service doesn't own user data; user existence is validated by the auth token.
### Port Interface
```go
// internal/port/preference.go
type PreferenceRepository interface {
// GetByUserID returns all preferences for a user as a map[key]value.
// Returns an empty map if the user has no preferences.
GetByUserID(ctx context.Context, userID string) (map[string]string, error)
// Upsert creates or updates preferences for a user.
// Only the provided keys are affected; existing keys not in the map are preserved.
Upsert(ctx context.Context, userID string, prefs map[string]string) error
}
```
### PostgreSQL Adapter
```go
// internal/adapter/postgres/preference.go
type PreferenceRepository struct {
db *sqlx.DB
logger *logging.Logger
}
func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) (map[string]string, error) {
// SELECT key, value FROM user_preferences WHERE user_id = $1
// Returns empty map if no rows
}
func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, prefs map[string]string) error {
// Uses a transaction with batch INSERT ... ON CONFLICT (user_id, key)
// DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
// One statement per key within a single transaction
}
```
## API Changes
### Endpoints
Both endpoints are mounted under `/api/preferences-api` and require JWT authentication.
#### GET /api/preferences-api/preferences/{user_id}
Retrieve all preferences for a user.
**Request:**
```
GET /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <jwt>
```
**Response (200 OK):**
```json
{
"data": {
"theme": "dark",
"language": "en",
"notifications_enabled": "true"
},
"meta": {
"request_id": "abc-123",
"timestamp": "2026-02-07T12:00:00Z"
}
}
```
**Response (200 OK, no preferences set):**
```json
{
"data": {},
"meta": { ... }
}
```
**Error Responses:**
- `400 Bad Request` — invalid UUID in path
- `401 Unauthorized` — missing or invalid JWT
- `403 Forbidden` — user_id does not match JWT subject
#### PUT /api/preferences-api/preferences/{user_id}
Create or update preferences (partial upsert).
**Request:**
```
PUT /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <jwt>
Content-Type: application/json
{
"theme": "dark",
"language": "fr"
}
```
**Response (200 OK):**
```json
{
"data": {
"theme": "dark",
"language": "fr",
"notifications_enabled": "true"
},
"meta": { ... }
}
```
Returns the full preference set after the update (including unchanged keys).
**Error Responses:**
- `400 Bad Request` — invalid UUID, unknown key, or invalid value (with descriptive message)
- `401 Unauthorized` — missing or invalid JWT
- `403 Forbidden` — user_id does not match JWT subject
### Request/Response DTOs
```go
// Handler request DTO for PUT
type UpdatePreferencesRequest struct {
Preferences map[string]string // Unmarshalled from JSON body
}
// Handler response DTO for GET and PUT
type PreferencesResponse struct {
Preferences map[string]string // Serialized as flat JSON object
}
```
The response `data` field is a flat `map[string]string`, not wrapped in a `preferences` key. This keeps the API simple: `data.theme`, not `data.preferences.theme`.
## Component Diagram
```
┌──────────────────────────────────────────────────────────────────┐
│ HTTP Client │
│ Authorization: Bearer <jwt> │
└──────────────────────┬───────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Chi Router │
│ /api/preferences-api/preferences/{user_id} [GET, PUT] │
│ │
│ Middleware Stack: │
│ ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐ │
│ │RequestID│→│ Tracing │→│ Logger │→│Recoverer │ │
│ └─────────┘ └─────────┘ └───────────┘ └──────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ Auth Middleware │ ← pkg/auth JWT validation │
│ └────────┬────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ Preference │ ← Handler: ownership check, │
│ │ Handler │ bind, validate, map errors │
│ └────────┬────────┘ │
└───────────────────────┼──────────────────────────────────────────┘
┌────────┴────────┐
│ Preference │ ← Service: domain validation,
│ Service │ orchestrate get/upsert
└────────┬────────┘
┌────────┴────────┐
│ Preference │ ← Port: interface
│ Repository │
└────────┬────────┘
┌────────┴────────┐
│ PostgreSQL │ ← Adapter: SQL queries,
│ Adapter │ ON CONFLICT upsert
└────────┬────────┘
┌────────┴────────┐
│ PostgreSQL │ ← user_preferences table
│ Database │
└─────────────────┘
```
## Error Handling Strategy
| Error Condition | Domain Error | HTTP Error | Status |
|----------------|-------------|-----------|--------|
| Invalid UUID in path | — | `httperror.BadRequest("invalid user ID format")` | 400 |
| Empty request body (PUT) | — | `httperror.BadRequest("request body is required")` | 400 |
| Unknown preference key | `ErrUnknownKey` | `httperror.BadRequest("unknown preference key: <key>")` | 400 |
| Invalid preference value | `ErrInvalidValue` | `httperror.BadRequest("invalid value '<val>' for key '<key>': allowed values are [...]")` | 400 |
| Missing/invalid JWT | — | Handled by auth middleware | 401 |
| user_id ≠ JWT subject | `ErrForbidden` | `httperror.Forbidden("cannot access preferences for another user")` | 403 |
| Database connection error | raw error | Passthrough → `app.Wrap` returns 500 | 500 |
**Error message strategy:** Validation errors include specific, actionable messages that tell the client what went wrong and what is allowed. For example: `"invalid value 'blue' for key 'theme': allowed values are [light, dark, system]"`.
### Ownership Check Flow
```
1. Auth middleware validates JWT → stores auth.User in context
2. Handler extracts user_id from URL path
3. Handler calls auth.GetUser(ctx) to get authenticated user
4. Handler compares user.ID == user_id path param
5. If mismatch → return httperror.Forbidden(...)
6. If match → proceed to service layer
```
This check lives in the handler, not the service, because it depends on HTTP/auth context. The service layer receives a validated `userID` string and trusts it.
## Security Considerations
### Authentication
- **All preference endpoints require JWT authentication** — no public access.
- Auth middleware is mandatory (not conditional on `AUTH_ENABLED` for preference routes). The config flag controls whether the example routes had auth; for preferences, auth is always required.
- JWT validation uses `pkg/auth.Middleware` with `auth.NewJWTValidator`.
### Authorization
- **Self-access only**: authenticated users can only read/write their own preferences.
- Ownership enforced at the handler layer by comparing `auth.GetUser(ctx).ID` with `{user_id}` path parameter.
- No admin override (explicitly out of scope per spec).
### Input Validation
- `user_id` path parameter validated as UUID format at handler layer.
- Preference keys validated against a strict allowlist — unknown keys rejected.
- Preference values validated per-key (enum check for theme/notifications, regex for language).
- Request body size bounded by `app.Bind` defaults (prevents oversized payloads).
- No SQL injection risk: all queries use parameterized statements (`$1`, `$2`).
### Data Exposure
- GET returns only the authenticated user's preferences — no cross-user data leakage.
- Error messages do not leak internal state (no stack traces, no database details).
- Preference values are non-sensitive (theme, language, notification toggle).
### Open Question Decisions (for design purposes)
1. **Default values**: GET returns only explicitly set keys. An empty `{}` is returned for users with no preferences. Clients are responsible for applying defaults. This avoids coupling the API to default values that may change.
2. **DELETE support**: Not included in this design (out of scope per spec). Can be added later without breaking changes.
3. **Extensibility**: New keys are added by updating the `AllowedKeys` map in `domain/preference.go`. This is a code change, which is acceptable — new keys require validation rules that belong in code.
4. **Admin access**: Not supported. Self-access only.
## Performance Considerations
### Query Performance
- **GET**: Single `SELECT ... WHERE user_id = $1` on a table indexed by `user_id`. Expected < 1ms for typical preference sets (3 keys). Well within p99 < 50ms target.
- **PUT**: Transaction with `INSERT ... ON CONFLICT` statements. One round-trip per upsert batch. Expected < 5ms for typical updates.
### Connection Pooling
- Uses `pkg/database.Pool` with default settings (25 max open, 5 max idle).
- Connection pool shared across all requests.
### No Caching Needed
- Preference reads are simple primary key lookups PostgreSQL handles these efficiently.
- Caching adds complexity (invalidation, stale data) with minimal benefit for this access pattern.
- If caching becomes needed later, it can be added at the service layer without changing the port interface.
### Table Size
- One row per user per preference key (max 3 rows per user currently).
- Even at 1M users × 3 keys = 3M rows, this is trivial for PostgreSQL.
## Migration / Rollout Plan
### Step 1: Remove Example Code
- Delete all `example`-related files: `domain/example.go`, `domain/errors.go` (replace), `port/example.go`, `service/example.go`, `service/example_test.go`, `adapter/memory/example.go`, `api/handlers/example.go`, `api/handlers/example_test.go`.
- This is explicitly required by the spec: "The existing example CRUD code should be replaced, not left alongside preference code."
### Step 2: Implement Domain Layer
- Create `domain/preference.go` with `Preference` type, `AllowedKeys`, validation functions.
- Create `domain/errors.go` with `ErrUnknownKey`, `ErrInvalidValue`, `ErrForbidden`.
- Test validation logic with unit tests.
### Step 3: Implement Port and Adapter
- Create `port/preference.go` with `PreferenceRepository` interface.
- Create `adapter/postgres/preference.go` implementing the port.
- Create `migrations/001_create_user_preferences.sql`.
### Step 4: Implement Service Layer
- Create `service/preference.go` with `PreferenceService`.
- Create `service/preference_test.go` with mock repository.
### Step 5: Implement Handler Layer
- Create `api/handlers/preference.go` with GET/PUT handlers and ownership check.
- Create `api/handlers/preference_test.go` covering success, validation, auth, and ownership cases.
### Step 6: Wire Routes and Spec
- Update `api/routes.go` to register preference routes with mandatory auth.
- Replace `api/spec.go` with preference endpoint documentation.
- Update `cmd/server/main.go` to initialize database pool, run migrations, wire dependencies.
### Step 7: Verify
- Run full test suite: `cd services/preferences-api && go test -v ./...`
- Manual smoke test with curl against local instance.
### Backward Compatibility
- No backward compatibility concerns the example CRUD API has no consumers. This is a scaffold replacement.
- The service name, port, and base path remain unchanged.
## File Inventory
| File | Action | Purpose |
|------|--------|---------|
| `cmd/server/main.go` | Modify | Add DB pool, migrations, wire preference service |
| `internal/domain/preference.go` | Create | Preference types, AllowedKeys, validation |
| `internal/domain/errors.go` | Replace | Domain errors for preferences |
| `internal/port/preference.go` | Create (replace example) | PreferenceRepository interface |
| `internal/service/preference.go` | Create (replace example) | Business logic |
| `internal/service/preference_test.go` | Create (replace example) | Service tests with mock |
| `internal/adapter/postgres/preference.go` | Create (replace memory) | PostgreSQL adapter |
| `internal/api/handlers/preference.go` | Create (replace example) | HTTP handlers |
| `internal/api/handlers/preference_test.go` | Create (replace example) | Handler tests |
| `internal/api/handlers/health.go` | Keep | No changes |
| `internal/api/routes.go` | Modify | New routes with mandatory auth |
| `internal/api/spec.go` | Replace | OpenAPI spec for preferences |
| `internal/config/config.go` | Keep | Already has DB and auth config |
| `migrations/001_create_user_preferences.sql` | Create | Database schema |
| `internal/adapter/memory/example.go` | Delete | Replaced by postgres adapter |
| `internal/domain/example.go` | Delete | Replaced by preference domain |
| `internal/port/example.go` | Delete | Replaced by preference port |
| `internal/service/example.go` | Delete | Replaced by preference service |
| `internal/service/example_test.go` | Delete | Replaced by preference tests |
| `internal/api/handlers/example.go` | Delete | Replaced by preference handlers |
| `internal/api/handlers/example_test.go` | Delete | Replaced by preference handler tests |