build: /design-feature user-preferences
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
rdev-worker 2026-02-07 23:32:27 +00:00
parent 6db90ac66d
commit 37e6dbe519
2 changed files with 413 additions and 1 deletions

View File

@ -0,0 +1,412 @@
# 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 |

View File

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