diff --git a/.sdlc/features/user-preferences/design.md b/.sdlc/features/user-preferences/design.md new file mode 100644 index 0000000..0e58f7e --- /dev/null +++ b/.sdlc/features/user-preferences/design.md @@ -0,0 +1,449 @@ +# Design: User Preferences API + +## Architecture Approach + +Replace the existing example CRUD scaffold in `services/preferences-api/` with a real user preferences domain. The hexagonal architecture layers remain identical in structure — only the domain model, service logic, port interface, adapter implementation, handlers, routes, and OpenAPI spec change. + +**What changes:** +- **Domain layer** — Remove `Example` entity; add `Preference` value object and `UserPreferences` aggregate with defaults/validation +- **Service layer** — Remove `ExampleService`; add `PreferenceService` with get-with-defaults and upsert-with-validation logic +- **Port layer** — Remove `ExampleRepository`; add `PreferenceRepository` interface for DB operations +- **Adapter layer** — Remove in-memory adapter; add PostgreSQL adapter using `pkg/database` (sqlx) +- **Handler layer** — Remove example handlers; add `GET` and `PUT` preference handlers +- **Routes** — Replace `/examples` routes with `/preferences/{user_id}` routes +- **OpenAPI spec** — Replace example schemas/paths with preference schemas/paths +- **Migrations** — Add `001_create_user_preferences.sql` +- **main.go** — Wire database connection, run migrations, inject PostgreSQL adapter + +**What stays the same:** +- Service port (8001), health endpoint, config structure, auth middleware pattern +- All `pkg/*` dependencies used identically to the scaffold +- Test patterns (mock repository for service tests, chi router for handler tests) + +## Data Model Changes + +### Domain Types + +```go +// internal/domain/preference.go + +// Known preference keys with their types and defaults +type PreferenceKey string + +const ( + KeyTheme PreferenceKey = "theme" + KeyLanguage PreferenceKey = "language" + KeyNotificationsEnabled PreferenceKey = "notifications_enabled" +) + +// PreferenceDefinition describes a known preference key +type PreferenceDefinition struct { + Key PreferenceKey + DefaultValue string + Validate func(value string) error +} + +// UserPreferences is the aggregate representing all preferences for a user +type UserPreferences struct { + UserID string + Preferences map[PreferenceKey]string // key -> serialized value +} +``` + +### Database Schema + +Single migration file: `services/preferences-api/migrations/001_create_user_preferences.sql` + +```sql +CREATE TABLE 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); +``` + +Each preference is a separate row. This is an EAV (entity-attribute-value) pattern that allows adding new preference keys without schema changes. + +### Value Serialization + +All values stored as TEXT in the database. Serialization rules: +- `theme` — stored as-is (`"light"`, `"dark"`, `"system"`) +- `language` — stored as-is (`"en"`, `"fr"`, etc.) +- `notifications_enabled` — stored as `"true"` or `"false"`, deserialized to JSON boolean in responses + +## API Changes + +### Removed Endpoints +- `GET /api/preferences-api/examples` — removed +- `GET /api/preferences-api/examples/{id}` — removed +- `POST /api/preferences-api/examples` — removed +- `PUT /api/preferences-api/examples/{id}` — removed +- `DELETE /api/preferences-api/examples/{id}` — removed + +### New Endpoints + +#### GET /api/preferences-api/preferences/{user_id} + +Returns all preferences for a user, merging stored values with server-defined defaults. + +- **Path param:** `user_id` — UUID format, validated +- **Auth:** In auth-protectable route group (enforcement opt-in via `AUTH_ENABLED`) +- **Response 200:** +```json +{ + "data": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "preferences": { + "theme": "dark", + "language": "en", + "notifications_enabled": true + } + }, + "meta": { "request_id": "...", "timestamp": "..." } +} +``` +- **Response 400:** Invalid `user_id` format + +#### PUT /api/preferences-api/preferences/{user_id} + +Creates or updates preferences for the given user. Only provided keys are updated; omitted keys retain their current value or default. + +- **Path param:** `user_id` — UUID format, validated +- **Auth:** In auth-protectable route group (enforcement opt-in via `AUTH_ENABLED`) +- **Request body:** +```json +{ + "preferences": { + "theme": "dark", + "language": "fr" + } +} +``` +- **Response 200:** Same shape as GET (returns full merged preferences after update) +- **Response 400:** Invalid `user_id`, unknown preference key, or invalid preference value + +#### Kept Endpoints +- `GET /api/preferences-api/health` — unchanged + +## Component Diagram + +``` +┌─────────────────────────────────────────────────────────┐ +│ HTTP Layer │ +│ │ +│ GET /preferences/{user_id} PUT /preferences/{user_id}│ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ PreferenceHandler │ │ +│ │ - Validates user_id (UUID) │ │ +│ │ - Binds PUT request body │ │ +│ │ - Maps domain errors → HTTP errors │ │ +│ │ - Returns envelope responses │ │ +│ └──────────────┬───────────────────────────┘ │ +└─────────────────┼───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Service Layer │ +│ ┌──────────────────────────────────────────┐ │ +│ │ PreferenceService │ │ +│ │ - GetPreferences(userID): │ │ +│ │ fetch stored → merge defaults │ │ +│ │ - UpdatePreferences(userID, prefs): │ │ +│ │ validate keys → validate values │ │ +│ │ → upsert → fetch merged result │ │ +│ └──────────────┬───────────────────────────┘ │ +└─────────────────┼───────────────────────────────────────┘ + │ uses port interface + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Port Layer (Interface) │ +│ ┌──────────────────────────────────────────┐ │ +│ │ PreferenceRepository (interface) │ │ +│ │ - GetByUserID(ctx, userID) │ │ +│ │ → []PreferenceRow, error │ │ +│ │ - Upsert(ctx, userID, key, value) │ │ +│ │ → error │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────┼───────────────────────────────────────┘ + │ implemented by + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Adapter Layer (PostgreSQL) │ +│ ┌──────────────────────────────────────────┐ │ +│ │ PostgresPreferenceRepository │ │ +│ │ - Uses sqlx via pkg/database │ │ +│ │ - GetByUserID: SELECT WHERE user_id=? │ │ +│ │ - Upsert: INSERT ON CONFLICT UPDATE │ │ +│ └──────────────┬───────────────────────────┘ │ +└─────────────────┼───────────────────────────────────────┘ + │ + ▼ + ┌───────────┐ + │ PostgreSQL │ + │ user_ │ + │ preferences│ + └───────────┘ +``` + +## Detailed Layer Design + +### Domain Layer (`internal/domain/`) + +**Files to create:** +- `preference.go` — Preference types, definitions, validation, defaults +- `errors.go` — Keep file, replace example errors with preference errors + +**`preference.go` responsibilities:** +1. Define `PreferenceKey` constants for known keys +2. Define `PreferenceDefinition` registry with default values and per-key validators +3. Provide `DefaultPreferences()` returning all keys with default values +4. Provide `ValidateKey(key string) error` — returns error if key is unknown +5. Provide `ValidateValue(key PreferenceKey, value string) error` — runs per-key validator +6. Provide `MergeWithDefaults(stored map[PreferenceKey]string) map[PreferenceKey]string` +7. Provide `SerializeForResponse(prefs map[PreferenceKey]string) map[string]any` — converts `"true"`/`"false"` to booleans for JSON + +**Validation rules:** +- `theme`: must be one of `light`, `dark`, `system` +- `language`: must match BCP 47 format (regex: `^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*$`) +- `notifications_enabled`: must be `"true"` or `"false"` + +**Domain errors:** +- `ErrUnknownPreferenceKey` — unknown key in PUT request +- `ErrInvalidPreferenceValue` — value fails validation for its key +- `ErrInvalidUserID` — user_id is not a valid UUID + +### Port Layer (`internal/port/`) + +**File to create:** +- `preference.go` — Replace `example.go` + +```go +type PreferenceRow struct { + UserID string + Key string + Value string + CreatedAt time.Time + UpdatedAt time.Time +} + +type PreferenceRepository interface { + GetByUserID(ctx context.Context, userID string) ([]PreferenceRow, error) + Upsert(ctx context.Context, userID string, key string, value string) error +} +``` + +The interface is minimal — no delete, no list-all-users. The service layer handles merging with defaults and batch upserts by calling `Upsert` in a loop (or a single batch query in the adapter). + +### Service Layer (`internal/service/`) + +**File to create:** +- `preference.go` — Replace `example.go` +- `preference_test.go` — Replace `example_test.go` + +**`PreferenceService` methods:** + +```go +func (s *PreferenceService) GetPreferences(ctx context.Context, userID string) (*PreferencesResult, error) +``` +1. Validate `userID` is a valid UUID → return `ErrInvalidUserID` if not +2. Call `repo.GetByUserID(ctx, userID)` to get stored rows +3. Convert rows to `map[PreferenceKey]string` +4. Merge with defaults via `domain.MergeWithDefaults()` +5. Return result with serialized preferences + +```go +func (s *PreferenceService) UpdatePreferences(ctx context.Context, userID string, input map[string]any) (*PreferencesResult, error) +``` +1. Validate `userID` is a valid UUID → return `ErrInvalidUserID` if not +2. For each key in input: + - Validate key is known → return `ErrUnknownPreferenceKey` if not + - Serialize value to string (booleans to `"true"`/`"false"`) + - Validate value → return `ErrInvalidPreferenceValue` if invalid +3. For each validated key-value pair, call `repo.Upsert(ctx, userID, key, value)` +4. Fetch and return full merged preferences (same as GetPreferences) + +**`PreferencesResult`:** +```go +type PreferencesResult struct { + UserID string + Preferences map[string]any // Serialized for JSON (booleans as bool, strings as string) +} +``` + +### Adapter Layer (`internal/adapter/postgres/`) + +**File to create:** +- `preference.go` — PostgreSQL implementation of `PreferenceRepository` + +**Queries:** +- `GetByUserID`: `SELECT key, value, created_at, updated_at FROM user_preferences WHERE user_id = $1` +- `Upsert`: `INSERT INTO user_preferences (user_id, key, value, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()` + +Uses `sqlx` from `pkg/database` pool. + +### Handler Layer (`internal/api/handlers/`) + +**File to create:** +- `preference.go` — Replace `example.go` +- `preference_test.go` — Replace `example_test.go` + +**Handler struct:** +```go +type PreferenceHandler struct { + service *service.PreferenceService + logger *logging.Logger +} +``` + +**GET handler (`GetPreferences`):** +1. Extract `user_id` from URL via `chi.URLParam(r, "user_id")` +2. Call `service.GetPreferences(ctx, userID)` +3. Map domain errors: `ErrInvalidUserID` → `httperror.BadRequest` +4. Return `httpresponse.OK(w, r, response)` + +**PUT handler (`UpdatePreferences`):** +1. Extract `user_id` from URL via `chi.URLParam(r, "user_id")` +2. Bind request body with `app.Bind(r, &req)` (not BindAndValidate — custom validation in service) +3. Call `service.UpdatePreferences(ctx, userID, req.Preferences)` +4. Map domain errors: + - `ErrInvalidUserID` → `httperror.BadRequest` + - `ErrUnknownPreferenceKey` → `httperror.BadRequest` + - `ErrInvalidPreferenceValue` → `httperror.BadRequest` +5. Return `httpresponse.OK(w, r, response)` + +**Request type:** +```go +type UpdatePreferencesRequest struct { + Preferences map[string]any `json:"preferences"` +} +``` + +**Response type:** +```go +type PreferencesResponse struct { + UserID string `json:"user_id"` + Preferences map[string]any `json:"preferences"` +} +``` + +### Routes (`internal/api/routes.go`) + +Replace example routes with: + +```go +// Public +r.Get("/api/preferences-api/health", app.Wrap(healthHandler.Check)) + +// Preferences (auth-protectable) +r.Route("/api/preferences-api", func(r chi.Router) { + if cfg.AuthEnabled { + r.Use(auth.Middleware(...)) + } + r.Get("/preferences/{user_id}", app.Wrap(prefHandler.GetPreferences)) + r.Put("/preferences/{user_id}", app.Wrap(prefHandler.UpdatePreferences)) +}) +``` + +### Entry Point (`cmd/server/main.go`) + +Changes: +1. Add database connection via `database.MustConnect()` +2. Embed and run migrations via `database.MustRunMigrations()` +3. Create `postgres.NewPreferenceRepository(pool)` instead of memory adapter +4. Create `service.NewPreferenceService(repo, logger)` instead of example service +5. Register new routes +6. Add DB pool shutdown hook via `app.OnShutdown()` + +### OpenAPI Spec (`internal/api/spec.go`) + +Replace example schemas with: +- `UserPreferences` schema — user_id (UUID) + preferences object +- `UpdatePreferencesRequest` schema — preferences object with known keys +- `GET /preferences/{user_id}` — 200, 400 +- `PUT /preferences/{user_id}` — 200, 400 + +## Error Handling Strategy + +| Error Source | Domain Error | HTTP Error | Status Code | +|---|---|---|---| +| Invalid user_id format | `ErrInvalidUserID` | `httperror.BadRequest` | 400 | +| Unknown preference key | `ErrUnknownPreferenceKey` | `httperror.BadRequest` | 400 | +| Invalid preference value | `ErrInvalidPreferenceValue` | `httperror.BadRequest` | 400 | +| Malformed JSON body | (from `app.Bind`) | `httperror.BadRequest` | 400 | +| Database connection failure | raw error | `httperror.Internal` (via Wrap) | 500 | +| Database query failure | raw error | `httperror.Internal` (via Wrap) | 500 | +| User has no stored preferences | Not an error | Returns defaults | 200 | + +**Key decisions:** +- GET for a nonexistent user returns 200 with all defaults — not 404. This simplifies client logic and matches the spec. +- All validation errors return 400 with a descriptive message including the offending key/value. +- Database errors are not exposed to clients — Wrap converts them to generic 500. + +## Security Considerations + +1. **Authentication:** Endpoints are placed in an auth-protectable route group. When `AUTH_ENABLED=true`, JWT middleware is applied. When false, endpoints are open. This matches the existing scaffold pattern. + +2. **Authorization:** No user_id-to-token enforcement in this feature (per spec's open question #1). Any authenticated user can read/write any user's preferences. This is acceptable for the initial implementation and can be tightened later with a middleware check. + +3. **Input validation:** + - `user_id` validated as UUID format before any DB query — prevents injection + - Preference keys validated against a whitelist — no arbitrary key creation + - Preference values validated per-key with strict rules — no freeform text in constrained fields + - Request body bound via `app.Bind()` which uses `json.Decoder` — safe JSON parsing + +4. **SQL injection:** All queries use parameterized statements via sqlx (`$1`, `$2` placeholders). No string interpolation in SQL. + +5. **Data exposure:** The API only returns preferences for the requested user_id. No list-all-users endpoint. No sensitive data in preference values (theme, language, notification toggle). + +6. **Rate limiting:** Not in scope for this feature but can be added via middleware later. + +## Performance Considerations + +1. **Query complexity:** Both queries are simple — `SELECT WHERE user_id` and `INSERT ON CONFLICT`. The primary key `(user_id, key)` and the index on `user_id` ensure O(log n) lookups. + +2. **Expected data volume:** Each user has at most 3 preference rows (currently). Even with millions of users, the `user_id` index makes lookups fast. + +3. **Upsert pattern:** PUT calls `Upsert` once per provided key. With 1-3 keys per request, this is 1-3 simple queries. If this becomes a bottleneck, a batch upsert with `unnest()` can replace the loop — but premature optimization is not warranted for 3 keys. + +4. **No caching needed:** Preferences are read infrequently (page load) and the query is fast. Adding a cache layer would add complexity without meaningful benefit at this scale. + +5. **Connection pooling:** Uses `pkg/database` pool with defaults (25 max open, 5 idle). Adequate for this workload. + +## Migration / Rollout Plan + +1. **Database migration first:** The `CREATE TABLE` migration is additive — it creates a new table and doesn't modify existing tables. Safe to run with zero downtime. + +2. **Code deployment:** Replace example endpoints with preference endpoints in a single deployment. Since the example endpoints are scaffold-only (no real consumers), this is a clean swap with no backwards compatibility concerns. + +3. **No data migration:** New table starts empty. All users get defaults on first GET. Preferences are populated as users make PUT requests. + +4. **Rollback:** If issues arise, revert the code deployment. The `user_preferences` table can remain (harmless) or be dropped in a subsequent migration. + +5. **Feature flag:** Not needed. The endpoints are new (replacing unused scaffolds), so there are no existing consumers to break. + +## File Change Summary + +| Action | File | Description | +|---|---|---| +| Create | `migrations/001_create_user_preferences.sql` | Database schema | +| Replace | `internal/domain/preference.go` | New domain (delete `example.go`) | +| Replace | `internal/domain/errors.go` | New domain errors | +| Replace | `internal/port/preference.go` | New repository interface (delete `example.go`) | +| Replace | `internal/service/preference.go` | New service logic (delete `example.go`) | +| Replace | `internal/service/preference_test.go` | New service tests (delete `example_test.go`) | +| Create | `internal/adapter/postgres/preference.go` | PostgreSQL adapter (delete `memory/example.go`) | +| Replace | `internal/api/handlers/preference.go` | New handlers (delete `example.go`) | +| Replace | `internal/api/handlers/preference_test.go` | New handler tests (delete `example_test.go`) | +| Modify | `internal/api/routes.go` | New route registration | +| Replace | `internal/api/spec.go` | New OpenAPI spec | +| Modify | `cmd/server/main.go` | Wire DB, migrations, new service | +| Keep | `internal/api/handlers/health.go` | Unchanged | +| Keep | `internal/config/config.go` | Unchanged (already has DB config) | +| Delete | `internal/adapter/memory/example.go` | Removed (replaced by postgres) | diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 5ff6c33..541917a 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