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
3624cb8e6d
commit
96af8d3c07
400
.sdlc/features/user-preferences/design.md
Normal file
400
.sdlc/features/user-preferences/design.md
Normal 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.
|
||||
@ -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