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

This commit is contained in:
rdev-worker 2026-02-08 18:22:12 +00:00
parent b2e88a92a3
commit 8e08dbd822
2 changed files with 407 additions and 1 deletions

View File

@ -0,0 +1,406 @@
# Design: User Preferences API
## Architecture Approach
Replace the existing example CRUD scaffold in `services/preferences-api/` with a user preferences system. The change follows the same hexagonal architecture already in place (domain → service → port → adapter), reusing all existing framework packages (`app`, `httperror`, `httpresponse`, `auth`, `openapi`).
**What changes:**
- **Domain layer**: Replace `Example` entity with `UserPreferences` value object containing typed preference fields and validation logic
- **Port layer**: Replace `ExampleRepository` with `PreferenceRepository` interface (Get + Upsert)
- **Adapter layer**: Replace in-memory example adapter with both an in-memory adapter (for tests) and a PostgreSQL adapter (for production)
- **Service layer**: Replace `ExampleService` with `PreferenceService` orchestrating authorization checks and default hydration
- **Handler layer**: Replace example CRUD handlers with two preference endpoints (GET + PUT)
- **Routes/Spec**: Replace all example routes and OpenAPI documentation with preference endpoints
- **Config/Main**: Wire PostgreSQL connection and new dependency graph
**What stays the same:**
- Service name, port (8001), route prefix (`/api/preferences-api/`)
- All framework conventions (handler pattern, error wrapping, response envelope)
- Project structure (directory layout, go.mod, Makefile, Dockerfile)
- Health endpoint
## Data Model Changes
### Domain Types
```go
// domain/preferences.go
type UserPreferences struct {
UserID string
Theme Theme
Language string
Notifications NotificationPreferences
UpdatedAt time.Time
}
type Theme string
const (
ThemeLight Theme = "light"
ThemeDark Theme = "dark"
ThemeSystem Theme = "system"
)
type DigestFrequency string
const (
DigestNone DigestFrequency = "none"
DigestDaily DigestFrequency = "daily"
DigestWeekly DigestFrequency = "weekly"
)
type NotificationPreferences struct {
Email bool
Push bool
Digest DigestFrequency
}
```
**Defaults** (returned when no saved preferences exist):
- `Theme`: `"system"`
- `Language`: `"en"`
- `Notifications.Email`: `true`
- `Notifications.Push`: `true`
- `Notifications.Digest`: `"weekly"`
**Validation** (pure domain logic, no framework dependencies):
- `Theme` must be one of `light`, `dark`, `system`
- `Language` must be one of the allowed BCP-47 tags: `en`, `fr`, `es`, `de`, `ja`
- `Digest` must be one of `none`, `daily`, `weekly`
- Unknown preference keys rejected at the handler layer via strict binding
### Database Schema
Single table in the existing PostgreSQL instance:
```sql
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY,
theme TEXT NOT NULL DEFAULT 'system',
language TEXT NOT NULL DEFAULT 'en',
notify_email BOOLEAN NOT NULL DEFAULT true,
notify_push BOOLEAN NOT NULL DEFAULT true,
notify_digest TEXT NOT NULL DEFAULT 'weekly',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
**Design decisions:**
- Flat columns (not JSONB) because the preference set is fixed and small — enables type safety, indexing, and simpler queries
- `user_id` is `TEXT` to match the JWT subject field (UUIDs stored as text)
- No `created_at``updated_at` serves as the only timestamp; preferences conceptually always exist (defaults)
- No foreign key to a users table — the preferences service is independent; user existence is validated by auth
### Migration Strategy
Schema creation handled at service startup via an `EnsureSchema()` method on the PostgreSQL adapter. This uses `CREATE TABLE IF NOT EXISTS` which is idempotent and safe for repeated runs. No migration framework needed for a single-table, new service.
## API Changes
### Endpoints
Both endpoints require authentication via `auth.Middleware()`.
#### GET /api/preferences-api/preferences/{user_id}
Retrieve preferences for the specified user.
**Authorization:** Authenticated user's JWT `ID` must match `{user_id}` in URL. Admin users (role `admin`) may read any user's preferences.
**Response 200:**
```json
{
"data": {
"user_id": "usr_abc123",
"theme": "dark",
"language": "en",
"notifications": {
"email": true,
"push": false,
"digest": "daily"
},
"updated_at": "2026-02-08T12:00:00Z"
},
"meta": {
"request_id": "req-xyz",
"timestamp": "2026-02-08T12:00:01Z"
}
}
```
**Response 403:** User ID mismatch (non-admin accessing another user's preferences).
**Behavior for non-existent preferences:** Returns 200 with default values (not 404). The `updated_at` field is omitted (zero value) to indicate defaults.
#### PUT /api/preferences-api/preferences/{user_id}
Create or fully replace preferences for the specified user (upsert semantics).
**Authorization:** Authenticated user's JWT `ID` must match `{user_id}`. Admin write is **not** permitted (spec: out of scope).
**Request body:**
```json
{
"theme": "dark",
"language": "fr",
"notifications": {
"email": true,
"push": false,
"digest": "daily"
}
}
```
**Request binding:** Use `app.BindAndValidateStrict(r, &req)` — strict mode rejects unknown JSON fields, satisfying the "unknown keys return 400" requirement.
**Response 200:**
```json
{
"data": {
"user_id": "usr_abc123",
"theme": "dark",
"language": "fr",
"notifications": {
"email": true,
"push": false,
"digest": "daily"
},
"updated_at": "2026-02-08T12:00:05Z"
},
"meta": { ... }
}
```
**Response 400:** Invalid values or unknown keys, with per-field validation details.
**Response 403:** User ID mismatch.
### Removed Endpoints
All example CRUD endpoints are removed:
- `GET /api/preferences-api/examples`
- `GET /api/preferences-api/examples/{id}`
- `POST /api/preferences-api/examples`
- `PUT /api/preferences-api/examples/{id}`
- `DELETE /api/preferences-api/examples/{id}`
## Component Diagram
```
┌─────────────────────────────────────────────────────┐
│ HTTP Client │
│ (Frontend / Admin Service) │
└──────────────────────┬──────────────────────────────┘
│ HTTPS
┌──────────────────────────────────────────────────────┐
│ chi Router + Middleware Stack │
│ ┌─────────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ RequestID │ │ Logger │ │ auth.Middleware() │ │
│ └─────────────┘ └──────────┘ └───────────────────┘ │
└──────────────────────┬───────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Handler Layer (internal/api/handlers/) │
│ ┌──────────────────────────────────────────────┐ │
│ │ PreferenceHandler │ │
│ │ - Get(w, r) error [GET .../{user_id}] │ │
│ │ - Update(w, r) error [PUT .../{user_id}] │ │
│ │ │ │
│ │ Responsibilities: │ │
│ │ - Extract {user_id} from URL │ │
│ │ - Bind & validate request body (strict) │ │
│ │ - Check auth: user_id == JWT user ID │ │
│ │ - Delegate to PreferenceService │ │
│ │ - Map domain errors to HTTP errors │ │
│ │ - Return response envelope │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Service Layer (internal/service/) │
│ ┌──────────────────────────────────────────────┐ │
│ │ PreferenceService │ │
│ │ - GetPreferences(ctx, userID) (*Prefs, err) │ │
│ │ - UpdatePreferences(ctx, userID, prefs) err │ │
│ │ │ │
│ │ Responsibilities: │ │
│ │ - Apply defaults when no stored prefs exist │ │
│ │ - Validate preference values (domain logic) │ │
│ │ - Delegate persistence to repository port │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────┘
│ calls port interface
┌──────────────────────────────────────────────────────┐
│ Port Layer (internal/port/) │
│ ┌──────────────────────────────────────────────┐ │
│ │ PreferenceRepository (interface) │ │
│ │ - Get(ctx, userID) (*Prefs, error) │ │
│ │ - Upsert(ctx, prefs *Prefs) error │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────┘
│ implemented by
┌────────┴────────┐
▼ ▼
┌────────────────────┐ ┌─────────────────────┐
│ Memory Adapter │ │ Postgres Adapter │
│ (tests) │ │ (production) │
│ │ │ │
│ map[string]*Prefs │ │ user_preferences │
│ │ │ table │
└────────────────────┘ └─────────────────────┘
```
## Error Handling Strategy
### Domain Errors
```go
// domain/errors.go
var (
ErrInvalidTheme = errors.New("invalid theme value")
ErrInvalidLanguage = errors.New("invalid language value")
ErrInvalidDigest = errors.New("invalid digest frequency")
)
```
Domain validation functions return these errors with descriptive messages. The service layer calls domain validation before persisting.
### Handler Error Mapping
| Domain Error | HTTP Status | HTTP Code | Message |
|---|---|---|---|
| `ErrInvalidTheme` | 400 | `BAD_REQUEST` | "theme must be one of: light, dark, system" |
| `ErrInvalidLanguage` | 400 | `BAD_REQUEST` | "language must be one of: en, fr, es, de, ja" |
| `ErrInvalidDigest` | 400 | `BAD_REQUEST` | "notifications.digest must be one of: none, daily, weekly" |
| Strict bind error (unknown fields) | 400 | `BAD_REQUEST` | Automatic from `app.BindAndValidateStrict` |
| Validation tag failure | 400 | `VALIDATION_ERROR` | Per-field details from `httpvalidation` |
| Auth user mismatch | 403 | `FORBIDDEN` | "access denied: cannot access another user's preferences" |
| No auth token | 401 | `UNAUTHORIZED` | Automatic from `auth.Middleware()` |
| DB connection failure | 500 | `INTERNAL_ERROR` | Logged; generic message to client |
| DB query failure | 500 | `INTERNAL_ERROR` | Logged; generic message to client |
### Validation Approach
Two layers of validation:
1. **Struct tag validation** via `app.BindAndValidateStrict()` — handles required fields, `oneof` constraints for enums, boolean type checking. Unknown JSON fields rejected automatically.
2. **Domain validation** via `UserPreferences.Validate()` — additional business rules if needed beyond struct tags. Keeps the domain layer self-contained.
The struct tag approach handles most validation needs directly:
```go
type UpdatePreferencesRequest struct {
Theme string `json:"theme" validate:"required,oneof=light dark system"`
Language string `json:"language" validate:"required,oneof=en fr es de ja"`
Notifications UpdateNotificationsRequest `json:"notifications" validate:"required"`
}
type UpdateNotificationsRequest struct {
Email bool `json:"email" validate:""`
Push bool `json:"push" validate:""`
Digest string `json:"digest" validate:"required,oneof=none daily weekly"`
}
```
## Security Considerations
### Authentication
- Both endpoints sit behind `auth.Middleware()` — unauthenticated requests get 401 automatically.
- JWT validation uses the existing `JWTValidator` with shared secret from `JWT_SECRET` env var.
### Authorization
- **Self-access only for writes:** Handler extracts `auth.GetUser(ctx).ID` and compares to `{user_id}` URL parameter. Mismatch returns 403.
- **Admin read access:** For GET only, if the authenticated user has role `admin` (checked via `user.HasRole("admin")`), they may read another user's preferences. This supports server-rendered contexts per the spec.
- **No admin write:** PUT strictly requires self-access. Even admins cannot modify another user's preferences.
### Input Validation
- Strict JSON binding rejects unknown fields (prevents key injection).
- Enum validation via struct tags constrains values to allowed sets.
- No SQL injection risk — using parameterized queries (`$1`, `$2` placeholders).
- User ID from URL is only used as a query parameter, never interpolated into SQL.
### Data Exposure
- Preferences contain no secrets or PII beyond the user ID.
- Error messages do not leak internal state (DB errors logged server-side, generic message to client).
## Performance Considerations
### Expected Load
- Preferences are read on every page load (high read frequency).
- Preferences are updated infrequently (low write frequency).
- Dataset is tiny per user (single row, ~6 columns).
### Query Complexity
- GET: Single-row lookup by primary key (`user_id`) — O(1) with index.
- PUT: Single-row upsert by primary key — O(1) with index.
- No joins, no pagination, no aggregations.
### Caching Strategy
- **Not needed for MVP.** Single-row PK lookups on PostgreSQL are sub-millisecond. The dataset fits entirely in PostgreSQL's buffer cache.
- **Future optimization:** If load increases, add an in-process LRU cache with short TTL (e.g., 30 seconds). Cache invalidation on PUT is straightforward since writes go through the same service instance.
### Connection Pooling
- Uses `DatabaseConfig` defaults: 25 max open connections, 5 max idle, 5-minute lifetime. Appropriate for the expected load.
## Migration / Rollout Plan
### Phase 1: Replace Scaffold (Single Deployment)
Since the example CRUD endpoints have no consumers, the rollout is a clean replacement:
1. Remove all example domain/port/adapter/service/handler files
2. Add preference domain/port/adapter/service/handler files
3. Update `routes.go` to register new endpoints
4. Update `spec.go` with new OpenAPI documentation
5. Update `main.go` to wire PostgreSQL adapter and new dependency graph
6. Schema created at startup via `CREATE TABLE IF NOT EXISTS`
### Rollback
- Revert the deployment to previous version. The `user_preferences` table persists harmlessly and can be dropped manually if needed.
### No Breaking Changes
- No existing consumers depend on the example endpoints.
- The health endpoint is preserved unchanged.
- The service name, port, and route prefix remain the same.
## File Change Summary
| File | Action | Description |
|---|---|---|
| `internal/domain/example.go` | **Delete** | Remove example entity |
| `internal/domain/errors.go` | **Replace** | Preference-specific domain errors |
| `internal/domain/preferences.go` | **Create** | `UserPreferences` type, validation, defaults |
| `internal/port/example.go` | **Delete** | Remove example port |
| `internal/port/preferences.go` | **Create** | `PreferenceRepository` interface |
| `internal/adapter/memory/example.go` | **Delete** | Remove example memory adapter |
| `internal/adapter/memory/preferences.go` | **Create** | In-memory preference adapter (tests) |
| `internal/adapter/postgres/preferences.go` | **Create** | PostgreSQL preference adapter |
| `internal/service/example.go` | **Delete** | Remove example service |
| `internal/service/example_test.go` | **Delete** | Remove example service tests |
| `internal/service/preferences.go` | **Create** | `PreferenceService` with business logic |
| `internal/service/preferences_test.go` | **Create** | Service unit tests |
| `internal/api/handlers/example.go` | **Delete** | Remove example handlers |
| `internal/api/handlers/example_test.go` | **Delete** | Remove example handler tests |
| `internal/api/handlers/preferences.go` | **Create** | GET/PUT preference handlers |
| `internal/api/handlers/preferences_test.go` | **Create** | Handler tests with in-memory adapter |
| `internal/api/routes.go` | **Modify** | Replace example routes with preference routes |
| `internal/api/spec.go` | **Modify** | Replace OpenAPI spec for preference endpoints |
| `cmd/server/main.go` | **Modify** | Wire PostgreSQL adapter and preference service |
| `internal/config/config.go` | **Modify** | Ensure database config is loaded (may already be) |
## Key Design Decisions
1. **Flat columns over JSONB**: Preferences are a fixed, small set. Flat columns give type safety, simpler queries, and no need for JSON path operations.
2. **Strict binding for unknown key rejection**: Using `app.BindAndValidateStrict()` naturally rejects unknown JSON fields, satisfying the spec requirement without custom validation code.
3. **Defaults in domain, not DB**: The `DefaultPreferences()` function lives in the domain layer. When the repository returns "not found", the service returns defaults. This keeps default logic testable and independent of storage.
4. **No separate "exists" check**: GET returns defaults for non-existent users (200, not 404). PUT uses `INSERT ... ON CONFLICT UPDATE` for atomic upsert. No need for existence checks.
5. **Authorization in handler, not middleware**: The user ID comparison is endpoint-specific logic (matching URL param to JWT), not a reusable middleware concern. Keeping it in the handler is simpler and more explicit.
6. **Admin read via role check**: Uses the existing `auth.User.HasRole("admin")` mechanism. No new auth infrastructure needed.
7. **Schema creation at startup**: `CREATE TABLE IF NOT EXISTS` in the adapter's constructor. Simple, idempotent, no migration tool dependency for a single new table.

View File

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