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