diff --git a/.sdlc/features/user-preferences/design.md b/.sdlc/features/user-preferences/design.md new file mode 100644 index 0000000..9387e7b --- /dev/null +++ b/.sdlc/features/user-preferences/design.md @@ -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. diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 6e9f98d..ac6aca9 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