# 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` |
