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

This commit is contained in:
rdev-worker 2026-02-08 09:12:44 +00:00
parent 257ad77471
commit 100b3c4035
2 changed files with 649 additions and 1 deletions

View File

@ -0,0 +1,648 @@
# Design: User Preferences API
## Architecture Approach
The feature replaces the existing example/scaffold CRUD resource in `preferences-api` with a real user preferences domain. The hexagonal architecture already in place is preserved — only the inner layers change.
**What changes:**
- **Domain layer** — New `UserPreferences` entity with validation, replacing `Example`
- **Port layer** — New `PreferencesRepository` interface, replacing `ExampleRepository`
- **Service layer** — New `PreferencesService` with get/upsert logic, replacing `ExampleService`
- **Adapter layer** — New PostgreSQL adapter (replacing in-memory `Example` adapter)
- **Handler layer** — Two new handlers (GET, PUT), replacing five example handlers
- **Routes** — New authenticated route group at `/api/preferences-api/preferences/{user_id}`
- **OpenAPI spec** — Updated with preferences schemas and endpoints
- **Migrations** — New SQL migration for `user_preferences` table
- **main.go** — Updated to wire database pool and new dependencies
**What is removed:**
- All `example` domain, port, service, adapter, handler, and test code
- The in-memory adapter (production uses PostgreSQL)
**What is unchanged:**
- Health check handler and route
- `config/config.go` (already supports DATABASE_URL, AUTH_ENABLED, JWT_SECRET)
- Dockerfile, Makefile, component.yaml, go.mod structure
## Data Model Changes
### New Domain Types
```go
// domain/preferences.go
type UserID string
type NotificationPreferences struct {
Email bool
Push bool
SMS bool
}
type Preferences struct {
Theme string
Language string
Notifications NotificationPreferences
}
type UserPreferences struct {
UserID UserID
Preferences Preferences
UpdatedAt time.Time
}
```
### Default Values
When no preferences exist for a user, the service returns defaults:
| Key | Default |
|-----|---------|
| `theme` | `"system"` |
| `language` | `"en"` |
| `notifications.email` | `true` |
| `notifications.push` | `true` |
| `notifications.sms` | `false` |
Defaults are defined as a function in the domain layer (`DefaultPreferences()`) — the single source of truth.
### Database Schema
**Table: `user_preferences`**
```sql
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY,
preferences JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**Design decisions:**
- `user_id` as `TEXT PRIMARY KEY` — no UUID type constraint; IDs come from the auth system
- `preferences` as `JSONB` — single document per user, supporting the spec's extensibility requirement (unknown keys preserved)
- `created_at` included for operational debugging even though it's not exposed in the API
- No foreign key to a users table — preferences-api is a standalone service
### Migration File
`migrations/001_create_user_preferences.sql`
Single idempotent migration using `IF NOT EXISTS`. Embedded via `//go:embed` per project convention.
## API Changes
### Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/preferences-api/preferences/{user_id}` | Required | Get preferences (returns defaults if none saved) |
| `PUT` | `/api/preferences-api/preferences/{user_id}` | Required | Create or replace preferences |
### GET `/api/preferences-api/preferences/{user_id}`
**Authorization:** Authenticated user's ID must match `{user_id}`, else 403.
**Behavior:**
1. Extract `user_id` from URL path
2. Verify authenticated user matches `user_id`
3. Query database for preferences
4. If no row exists, return default preferences
5. Return response with `{data, meta}` envelope
**Response (200):**
```json
{
"data": {
"user_id": "usr_abc123",
"preferences": {
"theme": "dark",
"language": "en",
"notifications": {
"email": true,
"push": true,
"sms": false
}
},
"updated_at": "2026-02-08T10:30:00Z"
},
"meta": {
"request_id": "...",
"timestamp": "..."
}
}
```
**When no preferences saved (200 with defaults):**
```json
{
"data": {
"user_id": "usr_abc123",
"preferences": {
"theme": "system",
"language": "en",
"notifications": {
"email": true,
"push": true,
"sms": false
}
},
"updated_at": "0001-01-01T00:00:00Z"
},
"meta": { ... }
}
```
The `updated_at` zero value signals "never saved". Alternatively, it could be omitted when returning defaults — but including it keeps the response shape consistent.
### PUT `/api/preferences-api/preferences/{user_id}`
**Authorization:** Authenticated user's ID must match `{user_id}`, else 403.
**Behavior:**
1. Extract `user_id` from URL path
2. Verify authenticated user matches `user_id`
3. Bind and validate request body
4. Run domain validation on known keys
5. Upsert into database (INSERT ON CONFLICT UPDATE)
6. Return saved preferences with `{data, meta}` envelope
**Request body:**
```json
{
"preferences": {
"theme": "dark",
"language": "en",
"notifications": {
"email": true,
"push": true,
"sms": false
}
}
}
```
**Validation rules (domain layer):**
- `theme`: Must be one of `"light"`, `"dark"`, `"system"` — if present
- `language`: Max 10 characters — if present
- Unknown top-level keys in `preferences`: preserved (per spec extensibility requirement)
- `notifications` sub-keys: booleans, no special validation needed (Go zero-value is `false`)
**Response (200):**
Same shape as GET response, with the just-saved data and current timestamp.
**Error (400):**
```json
{
"error": {
"code": "BAD_REQUEST",
"message": "invalid theme: must be one of light, dark, system"
},
"meta": { ... }
}
```
## Component Diagram
```
┌──────────────────────────────────────────────────────────────────┐
│ HTTP Layer │
│ │
│ auth.Middleware() ──▶ handlers.Preferences │
│ │ │
│ GET /preferences/{user_id} ──▶ Get() ──▶ httpresponse.OK() │
│ PUT /preferences/{user_id} ──▶ Put() ──▶ httpresponse.OK() │
│ │ │
│ mapDomainError() ──▶ httperror.* │
└────────────────┬─────────────────────────────────────────────────┘
┌────────────────▼─────────────────────────────────────────────────┐
│ Service Layer │
│ │
│ PreferencesService │
│ ├── GetPreferences(ctx, userID) → *UserPreferences, error │
│ │ └── returns defaults if repo returns ErrNotFound │
│ └── SetPreferences(ctx, userID, prefs) → *UserPreferences, err│
│ └── validates, then upserts via repo │
└────────────────┬─────────────────────────────────────────────────┘
┌────────────────▼─────────────────────────────────────────────────┐
│ Port Layer (Interface) │
│ │
│ PreferencesRepository │
│ ├── Get(ctx, userID) → *UserPreferences, error │
│ └── Upsert(ctx, prefs *UserPreferences) → error │
└────────────────┬─────────────────────────────────────────────────┘
┌────────────────▼─────────────────────────────────────────────────┐
│ Adapter Layer (PostgreSQL) │
│ │
│ postgres.PreferencesRepository │
│ ├── Get() → SELECT ... WHERE user_id = $1 │
│ └── Upsert() → INSERT ... ON CONFLICT (user_id) │
│ DO UPDATE SET preferences = $2, updated_at = $3│
│ │
│ Uses: database.Pool.DB (*sqlx.DB) │
└────────────────┬─────────────────────────────────────────────────┘
┌────────────────▼─────────────────────────────────────────────────┐
│ Domain Layer (Pure) │
│ │
│ UserPreferences, Preferences, NotificationPreferences │
│ DefaultPreferences() │
│ Validate() → error │
│ ErrInvalidTheme, ErrInvalidLanguage, ErrForbidden │
└──────────────────────────────────────────────────────────────────┘
```
## Layer-by-Layer Implementation Details
### Domain (`internal/domain/`)
**Files to create:**
- `preferences.go` — Types, constructors, `DefaultPreferences()`, `Validate()`
- `errors.go` — Updated with `ErrInvalidTheme`, `ErrInvalidLanguage`, `ErrForbidden`, `ErrPreferencesNotFound`
**Files to delete:**
- `example.go`
**Validation logic in `Preferences.Validate()`:**
```go
func (p *Preferences) Validate() error {
if p.Theme != "" {
switch p.Theme {
case "light", "dark", "system":
// valid
default:
return ErrInvalidTheme
}
}
if len([]rune(p.Language)) > 10 {
return ErrInvalidLanguage
}
return nil
}
```
Unknown keys: The spec says unknown keys are preserved but not validated. Since we store the full JSON document in a JSONB column, unknown keys survive naturally. The `Preferences` struct uses a map for extensibility:
```go
type Preferences struct {
Theme string `json:"theme"`
Language string `json:"language"`
Notifications NotificationPreferences `json:"notifications"`
Extra map[string]any `json:"-"` // captured via custom marshal/unmarshal
}
```
A custom `UnmarshalJSON`/`MarshalJSON` pair on `Preferences` decodes known fields into struct fields and captures everything else into `Extra`. On marshal, known fields and `Extra` are merged back. This preserves unknown keys through the round-trip without schema migrations.
### Port (`internal/port/`)
**Files to create:**
- `preferences.go``PreferencesRepository` interface
**Files to delete:**
- `example.go`
```go
type PreferencesRepository interface {
Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
Upsert(ctx context.Context, prefs *domain.UserPreferences) error
}
```
Only two methods needed — no List, Delete, or ExistsByName. The simple interface keeps the adapter thin.
### Service (`internal/service/`)
**Files to create:**
- `preferences.go``PreferencesService`
- `preferences_test.go` — Unit tests
**Files to delete:**
- `example.go`
- `example_test.go`
```go
type PreferencesService struct {
repo port.PreferencesRepository
logger *logging.Logger
}
func (s *PreferencesService) GetPreferences(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
func (s *PreferencesService) SetPreferences(ctx context.Context, userID domain.UserID, prefs domain.Preferences) (*domain.UserPreferences, error)
```
**GetPreferences logic:**
1. Call `repo.Get(ctx, userID)`
2. If `ErrPreferencesNotFound`, return `DefaultPreferences()` with the given `userID`
3. Otherwise return the stored preferences
**SetPreferences logic:**
1. Call `prefs.Validate()` — return domain error if invalid
2. Build `UserPreferences{UserID: userID, Preferences: prefs, UpdatedAt: time.Now().UTC()}`
3. Call `repo.Upsert(ctx, &userPrefs)`
4. Return the saved preferences
Authorization (checking user_id matches authenticated user) is done in the **handler layer**, not here — the service layer doesn't know about HTTP or JWT. This follows the existing pattern where `mapDomainError()` in handlers maps domain errors to HTTP errors.
### Adapter (`internal/adapter/postgres/`)
**Files to create:**
- `preferences.go` — PostgreSQL implementation of `PreferencesRepository`
**Files to delete:**
- `adapter/memory/example.go`
```go
type PreferencesRepository struct {
db *sqlx.DB
logger *logging.Logger
}
func (r *PreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) {
// SELECT user_id, preferences, updated_at FROM user_preferences WHERE user_id = $1
// If no rows: return domain.ErrPreferencesNotFound
// Unmarshal JSONB into domain.Preferences
}
func (r *PreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error {
// INSERT INTO user_preferences (user_id, preferences, updated_at)
// VALUES ($1, $2, $3)
// ON CONFLICT (user_id) DO UPDATE SET preferences = $2, updated_at = $3
// Marshal domain.Preferences to JSON for JSONB column
}
```
### Handlers (`internal/api/handlers/`)
**Files to create:**
- `preferences.go` — GET and PUT handlers
- `preferences_test.go` — Handler tests
**Files to delete:**
- `example.go`
- `example_test.go`
```go
type Preferences struct {
svc *service.PreferencesService
logger *logging.Logger
}
```
**Request/Response types:**
```go
type PutPreferencesRequest struct {
Preferences PreferencesPayload `json:"preferences" validate:"required"`
}
type PreferencesPayload struct {
Theme string `json:"theme,omitempty"`
Language string `json:"language,omitempty"`
Notifications *NotificationPreferencesPayload `json:"notifications,omitempty"`
}
type NotificationPreferencesPayload struct {
Email bool `json:"email"`
Push bool `json:"push"`
SMS bool `json:"sms"`
}
type PreferencesResponse struct {
UserID string `json:"user_id"`
Preferences PreferencesPayload `json:"preferences"`
UpdatedAt string `json:"updated_at"`
}
```
**Handler: Get**
```go
func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
// Authorization check
authUser := auth.GetUser(r.Context())
if authUser.ID != userID {
return httperror.Forbidden("access denied: can only access own preferences")
}
prefs, err := h.svc.GetPreferences(r.Context(), domain.UserID(userID))
if err != nil {
return mapDomainError(err)
}
return httpresponse.OK(w, r, toResponse(prefs))
}
```
**Handler: Put**
```go
func (h *Preferences) Put(w http.ResponseWriter, r *http.Request) error {
userID := chi.URLParam(r, "user_id")
// Authorization check
authUser := auth.GetUser(r.Context())
if authUser.ID != userID {
return httperror.Forbidden("access denied: can only modify own preferences")
}
var req PutPreferencesRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
prefs, err := h.svc.SetPreferences(r.Context(), domain.UserID(userID), toDomain(req.Preferences))
if err != nil {
return mapDomainError(err)
}
return httpresponse.OK(w, r, toResponse(prefs))
}
```
**Error mapping:**
```go
func mapDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrInvalidTheme):
return httperror.BadRequest("invalid theme: must be one of light, dark, system")
case errors.Is(err, domain.ErrInvalidLanguage):
return httperror.BadRequest("invalid language: must be at most 10 characters")
default:
return err // app.Wrap() will handle as 500
}
}
```
### Routes (`internal/api/routes.go`)
Replace example routes with:
```go
func RegisterRoutes(application *app.App, prefsSvc *service.PreferencesService, authCfg config.Config) {
logger := application.Logger()
healthHandler := &handlers.Health{Logger: logger}
prefsHandler := handlers.NewPreferences(prefsSvc, logger)
r := application.Router()
// Public routes
r.Get("/api/preferences-api/health", healthHandler.Check)
// Protected routes — auth required for all preference endpoints
r.Route("/api/preferences-api/preferences", func(r chi.Router) {
if authCfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: auth.NewJWTValidator(auth.JWTConfig{
Secret: []byte(authCfg.JWTSecret),
}),
}))
}
r.Get("/{user_id}", app.Wrap(prefsHandler.Get))
r.Put("/{user_id}", app.Wrap(prefsHandler.Put))
})
}
```
### OpenAPI Spec (`internal/api/spec.go`)
Update to define:
- Schema: `Preferences` (theme, language, notifications)
- Schema: `NotificationPreferences` (email, push, sms)
- Schema: `UserPreferencesResponse` (user_id, preferences, updated_at)
- Schema: `PutPreferencesRequest` (preferences object)
- Path: `GET /api/preferences-api/preferences/{user_id}` with bearer auth, 200/403 responses
- Path: `PUT /api/preferences-api/preferences/{user_id}` with bearer auth, 200/400/403 responses
- `{user_id}` path parameter
### main.go (`cmd/server/main.go`)
Update to:
1. Connect to PostgreSQL using `database.Connect()`
2. Run migrations using `database.MustRunMigrations()`
3. Create `postgres.PreferencesRepository` with `pool.DB`
4. Create `PreferencesService` with the repo
5. Register routes with service and auth config
6. Register `pool.Close()` on shutdown
## Error Handling Strategy
| Scenario | Layer | Error | HTTP Status |
|----------|-------|-------|-------------|
| Invalid theme value | Domain | `ErrInvalidTheme` | 400 Bad Request |
| Language too long | Domain | `ErrInvalidLanguage` | 400 Bad Request |
| Malformed JSON body | Handler (BindAndValidate) | Automatic | 400 Bad Request |
| Missing `preferences` field | Handler (BindAndValidate) | Validation | 400 Bad Request |
| User accessing another user's prefs | Handler | `httperror.Forbidden` | 403 Forbidden |
| No preferences saved yet | Service | (returns defaults) | 200 OK |
| Database connection failure | Adapter | raw error | 500 Internal |
| Database query failure | Adapter | raw error | 500 Internal |
**Key decisions:**
- GET never returns 404 — missing preferences yield defaults. This simplifies the frontend (no special "first time" flow).
- Authorization is checked in handlers before any service call, failing fast with 403.
- Domain validation errors are specific and mapped to descriptive 400 messages.
- Database errors bubble up as raw errors, caught by `app.Wrap()` and returned as 500 with the error logged server-side (not leaked to client).
## Security Considerations
### Authentication
- Both endpoints require `auth.Middleware()`. Unauthenticated requests receive 401.
- JWT validation via `pkg/auth.NewJWTValidator` with HMAC secret from config.
### Authorization
- **Owner-only access**: The `{user_id}` in the URL path must match `auth.GetUser(ctx).ID`. This is checked in the handler before calling the service layer.
- No admin override endpoint (out of scope per spec).
### Input Validation
- Request body bound and validated via `app.BindAndValidate()` — rejects malformed JSON and missing required fields.
- Domain-level validation for `theme` (enum) and `language` (max length).
- JSONB column stores raw preferences — unknown keys preserved but size is bounded by PostgreSQL's TOAST limit (~1GB). For practical limits, the handler can check `Content-Length` against a reasonable threshold (e.g., 64KB). This addresses the spec's open question about preference size limits.
### Data Boundaries
- Users can only read/write their own preferences — no cross-user data access.
- Error responses never leak internal details (database errors, stack traces).
- The `preferences` JSONB column is treated as opaque by the database — no SQL injection vector.
### SQL Injection
- All queries use parameterized statements (`$1`, `$2`) via `sqlx` — no string concatenation.
## Performance Considerations
### Expected Load
- Read-heavy workload: preferences fetched on every page load / session start.
- Writes are infrequent: users change preferences rarely.
### Query Performance
- **GET**: Single-row lookup by primary key (`user_id`) — O(1) with B-tree index.
- **PUT**: Upsert by primary key — O(1).
- No need for additional indexes. The primary key index is sufficient.
### Caching Strategy
- **Not implemented in this iteration** (out of scope). The single-row PK lookup is fast enough.
- If needed later: HTTP `Cache-Control` headers or an in-process cache with short TTL.
### Connection Pooling
- Uses `database.Pool` with configurable pool size (default: 25 open, 5 idle). Adequate for preferences traffic.
### Payload Size
- Preferences JSON is small (< 1KB typical). No pagination or streaming needed.
## Migration / Rollout Plan
### Step 1: Database Migration
The `001_create_user_preferences.sql` migration runs on startup via `database.MustRunMigrations()`. It uses `CREATE TABLE IF NOT EXISTS` for idempotency. No existing tables are modified or dropped.
### Step 2: Code Deployment
The service is fully backward-compatible at the infrastructure level:
- Same port (8001)
- Same health check path (`/api/preferences-api/health`)
- Example endpoints are removed, but nothing depends on them (they're scaffold)
### Step 3: Verification
- Health check confirms service starts and database is reachable
- GET returns default preferences for any authenticated user (no data seeding needed)
- PUT creates preferences on first save
### Rollback
- Revert to previous deployment. The `user_preferences` table can remain — it won't interfere with the example scaffold code.
- No destructive migrations — forward-only table creation.
## Open Questions Resolution
From the spec:
1. **Authorization model**: Design uses `auth.GetUser(ctx).ID` — the `User.ID` field populated by JWT validation. This maps to the `sub` claim or `uid` custom claim (both supported by `pkg/auth.JWTClaims`). No changes to auth package needed.
2. **Unknown preference keys**: Preserved via custom JSON marshaling on the `Preferences` struct. Known keys are validated; unknown keys pass through to JSONB storage unchanged.
3. **Preference size limit**: Addressed by checking request `Content-Length` in the handler (64KB default). This prevents abuse without requiring schema changes.
## File Change Summary
| Action | File |
|--------|------|
| **Create** | `migrations/001_create_user_preferences.sql` |
| **Create** | `internal/domain/preferences.go` |
| **Create** | `internal/port/preferences.go` |
| **Create** | `internal/service/preferences.go` |
| **Create** | `internal/service/preferences_test.go` |
| **Create** | `internal/adapter/postgres/preferences.go` |
| **Create** | `internal/api/handlers/preferences.go` |
| **Create** | `internal/api/handlers/preferences_test.go` |
| **Modify** | `internal/domain/errors.go` |
| **Modify** | `internal/api/routes.go` |
| **Modify** | `internal/api/spec.go` |
| **Modify** | `cmd/server/main.go` |
| **Delete** | `internal/domain/example.go` |
| **Delete** | `internal/port/example.go` |
| **Delete** | `internal/service/example.go` |
| **Delete** | `internal/service/example_test.go` |
| **Delete** | `internal/adapter/memory/example.go` |
| **Delete** | `internal/api/handlers/example.go` |
| **Delete** | `internal/api/handlers/example_test.go` |

View File

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