build: /design-feature user-preferences
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
6db90ac66d
commit
37e6dbe519
412
.sdlc/features/user-preferences/design.md
Normal file
412
.sdlc/features/user-preferences/design.md
Normal 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 |
|
||||
@ -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