From 37e6dbe519a695851ac00f69e16dc2a7077ddc9e Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Sat, 7 Feb 2026 23:32:27 +0000 Subject: [PATCH] build: /design-feature user-preferences --- .sdlc/features/user-preferences/design.md | 412 ++++++++++++++++++ .sdlc/features/user-preferences/manifest.yaml | 2 +- 2 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 .sdlc/features/user-preferences/design.md diff --git a/.sdlc/features/user-preferences/design.md b/.sdlc/features/user-preferences/design.md new file mode 100644 index 0000000..c8814ce --- /dev/null +++ b/.sdlc/features/user-preferences/design.md @@ -0,0 +1,412 @@ +# Design: User Preferences API + +## Architecture Approach + +Replace the existing example CRUD scaffold in `services/preferences-api/` with a preference-specific domain following the same hexagonal architecture pattern: **domain → service → port (interface) → adapter (implementation)**. + +### What Changes + +| Layer | Action | Description | +|-------|--------|-------------| +| **Domain** | Replace | New `Preference` and `PreferenceKey` types with validation; remove `Example` entity | +| **Port** | Replace | New `PreferenceRepository` interface with `GetByUserID` and `Upsert` methods | +| **Adapter** | Replace | New PostgreSQL adapter (replaces in-memory); new migration for `user_preferences` table | +| **Service** | Replace | New `PreferenceService` with get/upsert business logic, key/value validation | +| **Handlers** | Replace | New `Preference` handler for GET and PUT endpoints with auth enforcement | +| **Routes** | Modify | Update route registration: both endpoints require auth, add user_id ownership check | +| **Spec** | Replace | New OpenAPI documentation for preference endpoints | +| **Main** | Modify | Wire PostgreSQL pool, run migrations, inject into preference service | + +### What Stays the Same + +- Service name (`preferences-api`), port (`8001`), base path (`/api/preferences-api`) +- Health check endpoint and handler +- Config loading pattern (extended with database config) +- All `pkg/*` dependencies remain unchanged +- Makefile, Dockerfile, component.yaml structure + +## Data Model Changes + +### Domain Types + +```go +// internal/domain/preference.go + +// AllowedKeys defines the valid preference keys and their allowed values. +var AllowedKeys = map[string][]string{ + "theme": {"light", "dark", "system"}, + "language": {}, // validated via regex: ^[a-z]{2}$ (ISO 639-1) + "notifications_enabled": {"true", "false"}, +} + +// Preference represents a single user preference key-value pair. +type Preference struct { + UserID string + Key string + Value string +} + +// Validate checks that Key is known and Value is valid for that key. +func (p *Preference) Validate() error { ... } + +// ValidateKey checks if a key is in the allowed set. +func ValidateKey(key string) error { ... } + +// ValidateValue checks if a value is valid for the given key. +func ValidateValue(key, value string) error { ... } +``` + +```go +// internal/domain/errors.go + +var ( + ErrUnknownKey = errors.New("unknown preference key") + ErrInvalidValue = errors.New("invalid preference value") + ErrForbidden = errors.New("access denied") +) +``` + +### Database Schema + +```sql +-- migrations/001_create_user_preferences.sql + +CREATE TABLE IF NOT EXISTS user_preferences ( + user_id UUID NOT NULL, + key VARCHAR(64) NOT NULL, + value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, key) +); + +CREATE INDEX idx_user_preferences_user_id ON user_preferences (user_id); +``` + +**Design decisions:** +- **Composite primary key** `(user_id, key)` — enforces one value per key per user, enables efficient upsert via `ON CONFLICT`. +- **Key-value model** rather than a wide row — allows adding new preference keys without schema migration. +- **Index on `user_id`** — supports efficient retrieval of all preferences for a single user. +- **No foreign key to a users table** — the preferences service doesn't own user data; user existence is validated by the auth token. + +### Port Interface + +```go +// internal/port/preference.go + +type PreferenceRepository interface { + // GetByUserID returns all preferences for a user as a map[key]value. + // Returns an empty map if the user has no preferences. + GetByUserID(ctx context.Context, userID string) (map[string]string, error) + + // Upsert creates or updates preferences for a user. + // Only the provided keys are affected; existing keys not in the map are preserved. + Upsert(ctx context.Context, userID string, prefs map[string]string) error +} +``` + +### PostgreSQL Adapter + +```go +// internal/adapter/postgres/preference.go + +type PreferenceRepository struct { + db *sqlx.DB + logger *logging.Logger +} + +func (r *PreferenceRepository) GetByUserID(ctx context.Context, userID string) (map[string]string, error) { + // SELECT key, value FROM user_preferences WHERE user_id = $1 + // Returns empty map if no rows +} + +func (r *PreferenceRepository) Upsert(ctx context.Context, userID string, prefs map[string]string) error { + // Uses a transaction with batch INSERT ... ON CONFLICT (user_id, key) + // DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + // One statement per key within a single transaction +} +``` + +## API Changes + +### Endpoints + +Both endpoints are mounted under `/api/preferences-api` and require JWT authentication. + +#### GET /api/preferences-api/preferences/{user_id} + +Retrieve all preferences for a user. + +**Request:** +``` +GET /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000 +Authorization: Bearer +``` + +**Response (200 OK):** +```json +{ + "data": { + "theme": "dark", + "language": "en", + "notifications_enabled": "true" + }, + "meta": { + "request_id": "abc-123", + "timestamp": "2026-02-07T12:00:00Z" + } +} +``` + +**Response (200 OK, no preferences set):** +```json +{ + "data": {}, + "meta": { ... } +} +``` + +**Error Responses:** +- `400 Bad Request` — invalid UUID in path +- `401 Unauthorized` — missing or invalid JWT +- `403 Forbidden` — user_id does not match JWT subject + +#### PUT /api/preferences-api/preferences/{user_id} + +Create or update preferences (partial upsert). + +**Request:** +``` +PUT /api/preferences-api/preferences/550e8400-e29b-41d4-a716-446655440000 +Authorization: Bearer +Content-Type: application/json + +{ + "theme": "dark", + "language": "fr" +} +``` + +**Response (200 OK):** +```json +{ + "data": { + "theme": "dark", + "language": "fr", + "notifications_enabled": "true" + }, + "meta": { ... } +} +``` + +Returns the full preference set after the update (including unchanged keys). + +**Error Responses:** +- `400 Bad Request` — invalid UUID, unknown key, or invalid value (with descriptive message) +- `401 Unauthorized` — missing or invalid JWT +- `403 Forbidden` — user_id does not match JWT subject + +### Request/Response DTOs + +```go +// Handler request DTO for PUT +type UpdatePreferencesRequest struct { + Preferences map[string]string // Unmarshalled from JSON body +} + +// Handler response DTO for GET and PUT +type PreferencesResponse struct { + Preferences map[string]string // Serialized as flat JSON object +} +``` + +The response `data` field is a flat `map[string]string`, not wrapped in a `preferences` key. This keeps the API simple: `data.theme`, not `data.preferences.theme`. + +## Component Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ HTTP Client │ +│ Authorization: Bearer │ +└──────────────────────┬───────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Chi Router │ +│ /api/preferences-api/preferences/{user_id} [GET, PUT] │ +│ │ +│ Middleware Stack: │ +│ ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐ │ +│ │RequestID│→│ Tracing │→│ Logger │→│Recoverer │ │ +│ └─────────┘ └─────────┘ └───────────┘ └──────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ Auth Middleware │ ← pkg/auth JWT validation │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ Preference │ ← Handler: ownership check, │ +│ │ Handler │ bind, validate, map errors │ +│ └────────┬────────┘ │ +└───────────────────────┼──────────────────────────────────────────┘ + │ + ┌────────┴────────┐ + │ Preference │ ← Service: domain validation, + │ Service │ orchestrate get/upsert + └────────┬────────┘ + │ + ┌────────┴────────┐ + │ Preference │ ← Port: interface + │ Repository │ + └────────┬────────┘ + │ + ┌────────┴────────┐ + │ PostgreSQL │ ← Adapter: SQL queries, + │ Adapter │ ON CONFLICT upsert + └────────┬────────┘ + │ + ┌────────┴────────┐ + │ PostgreSQL │ ← user_preferences table + │ Database │ + └─────────────────┘ +``` + +## Error Handling Strategy + +| Error Condition | Domain Error | HTTP Error | Status | +|----------------|-------------|-----------|--------| +| Invalid UUID in path | — | `httperror.BadRequest("invalid user ID format")` | 400 | +| Empty request body (PUT) | — | `httperror.BadRequest("request body is required")` | 400 | +| Unknown preference key | `ErrUnknownKey` | `httperror.BadRequest("unknown preference key: ")` | 400 | +| Invalid preference value | `ErrInvalidValue` | `httperror.BadRequest("invalid value '' for key '': allowed values are [...]")` | 400 | +| Missing/invalid JWT | — | Handled by auth middleware | 401 | +| user_id ≠ JWT subject | `ErrForbidden` | `httperror.Forbidden("cannot access preferences for another user")` | 403 | +| Database connection error | raw error | Passthrough → `app.Wrap` returns 500 | 500 | + +**Error message strategy:** Validation errors include specific, actionable messages that tell the client what went wrong and what is allowed. For example: `"invalid value 'blue' for key 'theme': allowed values are [light, dark, system]"`. + +### Ownership Check Flow + +``` +1. Auth middleware validates JWT → stores auth.User in context +2. Handler extracts user_id from URL path +3. Handler calls auth.GetUser(ctx) to get authenticated user +4. Handler compares user.ID == user_id path param +5. If mismatch → return httperror.Forbidden(...) +6. If match → proceed to service layer +``` + +This check lives in the handler, not the service, because it depends on HTTP/auth context. The service layer receives a validated `userID` string and trusts it. + +## Security Considerations + +### Authentication +- **All preference endpoints require JWT authentication** — no public access. +- Auth middleware is mandatory (not conditional on `AUTH_ENABLED` for preference routes). The config flag controls whether the example routes had auth; for preferences, auth is always required. +- JWT validation uses `pkg/auth.Middleware` with `auth.NewJWTValidator`. + +### Authorization +- **Self-access only**: authenticated users can only read/write their own preferences. +- Ownership enforced at the handler layer by comparing `auth.GetUser(ctx).ID` with `{user_id}` path parameter. +- No admin override (explicitly out of scope per spec). + +### Input Validation +- `user_id` path parameter validated as UUID format at handler layer. +- Preference keys validated against a strict allowlist — unknown keys rejected. +- Preference values validated per-key (enum check for theme/notifications, regex for language). +- Request body size bounded by `app.Bind` defaults (prevents oversized payloads). +- No SQL injection risk: all queries use parameterized statements (`$1`, `$2`). + +### Data Exposure +- GET returns only the authenticated user's preferences — no cross-user data leakage. +- Error messages do not leak internal state (no stack traces, no database details). +- Preference values are non-sensitive (theme, language, notification toggle). + +### Open Question Decisions (for design purposes) + +1. **Default values**: GET returns only explicitly set keys. An empty `{}` is returned for users with no preferences. Clients are responsible for applying defaults. This avoids coupling the API to default values that may change. +2. **DELETE support**: Not included in this design (out of scope per spec). Can be added later without breaking changes. +3. **Extensibility**: New keys are added by updating the `AllowedKeys` map in `domain/preference.go`. This is a code change, which is acceptable — new keys require validation rules that belong in code. +4. **Admin access**: Not supported. Self-access only. + +## Performance Considerations + +### Query Performance +- **GET**: Single `SELECT ... WHERE user_id = $1` on a table indexed by `user_id`. Expected < 1ms for typical preference sets (3 keys). Well within p99 < 50ms target. +- **PUT**: Transaction with `INSERT ... ON CONFLICT` statements. One round-trip per upsert batch. Expected < 5ms for typical updates. + +### Connection Pooling +- Uses `pkg/database.Pool` with default settings (25 max open, 5 max idle). +- Connection pool shared across all requests. + +### No Caching Needed +- Preference reads are simple primary key lookups — PostgreSQL handles these efficiently. +- Caching adds complexity (invalidation, stale data) with minimal benefit for this access pattern. +- If caching becomes needed later, it can be added at the service layer without changing the port interface. + +### Table Size +- One row per user per preference key (max 3 rows per user currently). +- Even at 1M users × 3 keys = 3M rows, this is trivial for PostgreSQL. + +## Migration / Rollout Plan + +### Step 1: Remove Example Code +- Delete all `example`-related files: `domain/example.go`, `domain/errors.go` (replace), `port/example.go`, `service/example.go`, `service/example_test.go`, `adapter/memory/example.go`, `api/handlers/example.go`, `api/handlers/example_test.go`. +- This is explicitly required by the spec: "The existing example CRUD code should be replaced, not left alongside preference code." + +### Step 2: Implement Domain Layer +- Create `domain/preference.go` with `Preference` type, `AllowedKeys`, validation functions. +- Create `domain/errors.go` with `ErrUnknownKey`, `ErrInvalidValue`, `ErrForbidden`. +- Test validation logic with unit tests. + +### Step 3: Implement Port and Adapter +- Create `port/preference.go` with `PreferenceRepository` interface. +- Create `adapter/postgres/preference.go` implementing the port. +- Create `migrations/001_create_user_preferences.sql`. + +### Step 4: Implement Service Layer +- Create `service/preference.go` with `PreferenceService`. +- Create `service/preference_test.go` with mock repository. + +### Step 5: Implement Handler Layer +- Create `api/handlers/preference.go` with GET/PUT handlers and ownership check. +- Create `api/handlers/preference_test.go` covering success, validation, auth, and ownership cases. + +### Step 6: Wire Routes and Spec +- Update `api/routes.go` to register preference routes with mandatory auth. +- Replace `api/spec.go` with preference endpoint documentation. +- Update `cmd/server/main.go` to initialize database pool, run migrations, wire dependencies. + +### Step 7: Verify +- Run full test suite: `cd services/preferences-api && go test -v ./...` +- Manual smoke test with curl against local instance. + +### Backward Compatibility +- No backward compatibility concerns — the example CRUD API has no consumers. This is a scaffold replacement. +- The service name, port, and base path remain unchanged. + +## File Inventory + +| File | Action | Purpose | +|------|--------|---------| +| `cmd/server/main.go` | Modify | Add DB pool, migrations, wire preference service | +| `internal/domain/preference.go` | Create | Preference types, AllowedKeys, validation | +| `internal/domain/errors.go` | Replace | Domain errors for preferences | +| `internal/port/preference.go` | Create (replace example) | PreferenceRepository interface | +| `internal/service/preference.go` | Create (replace example) | Business logic | +| `internal/service/preference_test.go` | Create (replace example) | Service tests with mock | +| `internal/adapter/postgres/preference.go` | Create (replace memory) | PostgreSQL adapter | +| `internal/api/handlers/preference.go` | Create (replace example) | HTTP handlers | +| `internal/api/handlers/preference_test.go` | Create (replace example) | Handler tests | +| `internal/api/handlers/health.go` | Keep | No changes | +| `internal/api/routes.go` | Modify | New routes with mandatory auth | +| `internal/api/spec.go` | Replace | OpenAPI spec for preferences | +| `internal/config/config.go` | Keep | Already has DB and auth config | +| `migrations/001_create_user_preferences.sql` | Create | Database schema | +| `internal/adapter/memory/example.go` | Delete | Replaced by postgres adapter | +| `internal/domain/example.go` | Delete | Replaced by preference domain | +| `internal/port/example.go` | Delete | Replaced by preference port | +| `internal/service/example.go` | Delete | Replaced by preference service | +| `internal/service/example_test.go` | Delete | Replaced by preference tests | +| `internal/api/handlers/example.go` | Delete | Replaced by preference handlers | +| `internal/api/handlers/example_test.go` | Delete | Replaced by preference handler tests | diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index bf4a3ee..e14c1e4 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -10,7 +10,7 @@ artifacts: status: pending path: audit.md design: - status: pending + status: draft path: design.md qa_plan: status: pending