diff --git a/.sdlc/features/user-preferences/design.md b/.sdlc/features/user-preferences/design.md new file mode 100644 index 0000000..e250b09 --- /dev/null +++ b/.sdlc/features/user-preferences/design.md @@ -0,0 +1,363 @@ +# Design: User Preferences API + +## Architecture Approach + +Replace the example CRUD scaffolding in `services/preferences-api` with a real user preferences domain. All six layers of the hexagonal architecture change: + +| Layer | What changes | +|-------|-------------| +| **Domain** | Replace `Example` entity with `UserPreferences` entity. New validation for theme, language, notification fields. | +| **Port** | Replace `ExampleRepository` with `PreferencesRepository` (2 methods: `Get`, `Upsert`). | +| **Adapter** | Add `internal/adapter/postgres/` with a JSONB-backed PostgreSQL implementation. Remove `internal/adapter/memory/`. | +| **Service** | Replace `ExampleService` with `PreferencesService` (2 use cases: `GetPreferences`, `UpdatePreferences`). | +| **Handlers** | Replace example CRUD handlers with `GET /preferences/{user_id}` and `PUT /preferences/{user_id}`. | +| **API** | Update routes and OpenAPI spec. Remove all example endpoint definitions. | + +The existing `main.go` wiring, config, and health handler remain. `main.go` changes to connect to PostgreSQL (via `pkg/database`) and run migrations on startup. + +### Design Decisions + +1. **Return defaults for unknown users (200, not 404):** Simpler frontend DX. The service returns a default `UserPreferences` struct when no row exists. +2. **Reject unknown preference keys:** Use `app.BindAndValidateStrict()` to reject unknown JSON fields. This catches typos and prevents silent data loss. Forward compatibility can be added later when new keys are defined. +3. **Accept any valid UUID for `user_id`:** No inter-service call to validate user existence. The preferences service is a simple key-value store keyed by UUID. This avoids coupling and latency. +4. **JSONB for preferences storage:** Single `preferences` JSONB column for the nested preference object. One row per user. Flexible schema that doesn't require migrations when adding new preference keys in the future. +5. **Deep merge on PUT:** The service performs a deep merge of the incoming JSON with existing preferences. Keys not included in the request body remain unchanged. Nested objects (like `notifications`) are merged recursively, not replaced wholesale. + +## Data Model Changes + +### New Table: `user_preferences` + +```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 file: `services/preferences-api/migrations/001_create_user_preferences.sql` + +### Domain Types + +```go +// domain/preferences.go + +type UserID string + +type Preferences struct { + Theme string `json:"theme"` + Language string `json:"language"` + Notifications NotificationSettings `json:"notifications"` +} + +type NotificationSettings struct { + Email bool `json:"email"` + Push bool `json:"push"` + Digest string `json:"digest"` +} + +type UserPreferences struct { + UserID UserID + Preferences Preferences + UpdatedAt time.Time +} +``` + +### Default Values + +```go +func DefaultPreferences() Preferences { + return Preferences{ + Theme: "system", + Language: "en", + Notifications: NotificationSettings{ + Email: true, + Push: true, + Digest: "weekly", + }, + } +} +``` + +### Domain Validation + +Validation lives in the domain layer, called by the service layer before persistence: + +```go +func (p *Preferences) Validate() error { ... } +``` + +| Field | Rule | Error | +|-------|------|-------| +| `theme` | Must be `"light"`, `"dark"`, or `"system"` | `ErrInvalidTheme` | +| `language` | Must be non-empty string | `ErrInvalidLanguage` | +| `notifications.email` | Boolean (validated by JSON binding) | N/A | +| `notifications.push` | Boolean (validated by JSON binding) | N/A | +| `notifications.digest` | Must be `"daily"`, `"weekly"`, or `"never"` | `ErrInvalidDigest` | + +## API Changes + +### Endpoints + +All routes mounted under `/api/preferences-api`. + +#### GET `/api/preferences-api/preferences/{user_id}` + +Retrieve preferences for a user. Returns defaults if no preferences are stored. + +**Path Parameter:** +- `user_id` (UUID, required) - Validated with `uuid.Parse()` + +**Response 200:** +```json +{ + "data": { + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "preferences": { + "theme": "dark", + "language": "en", + "notifications": { + "email": true, + "push": false, + "digest": "weekly" + } + }, + "updated_at": "2026-02-09T12:00:00Z" + }, + "meta": { + "request_id": "...", + "timestamp": "..." + } +} +``` + +**Response 400:** Invalid `user_id` format. + +#### PUT `/api/preferences-api/preferences/{user_id}` + +Create or update preferences (upsert with deep merge). + +**Path Parameter:** +- `user_id` (UUID, required) + +**Request Body:** +```json +{ + "preferences": { + "theme": "light", + "notifications": { + "push": true + } + } +} +``` + +Only provided keys are changed. Omitted keys retain their current value (or default if no row exists). + +**Response 200:** Full merged preference set after update. + +**Response 400:** Invalid `user_id` or invalid preference values. + +### Request/Response Types (Handler Layer) + +```go +// UpdatePreferencesRequest is the PUT request body. +type UpdatePreferencesRequest struct { + Preferences PreferencesInput `json:"preferences" validate:"required"` +} + +// PreferencesInput uses pointers to distinguish "not provided" from zero values. +type PreferencesInput struct { + Theme *string `json:"theme,omitempty"` + Language *string `json:"language,omitempty"` + Notifications *NotificationsInput `json:"notifications,omitempty"` +} + +type NotificationsInput struct { + Email *bool `json:"email,omitempty"` + Push *bool `json:"push,omitempty"` + Digest *string `json:"digest,omitempty"` +} + +// PreferencesResponse is the GET/PUT response shape. +type PreferencesResponse struct { + UserID string `json:"user_id"` + Preferences domain.Preferences `json:"preferences"` + UpdatedAt string `json:"updated_at"` +} +``` + +## Component Diagram + +``` +┌─────────────────────────────────────────────────────────┐ +│ HTTP Client │ +└────────────┬────────────────────────┬───────────────────┘ + │ GET /preferences/{id} │ PUT /preferences/{id} + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ api/routes.go │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ app.Wrap(handler.Get) app.Wrap(handler.Upsert) │ │ +│ └───────────────────────────────────────────────────┘ │ +└────────────┬────────────────────────┬───────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ handlers/preferences.go │ +│ - Validates user_id (UUID parse) │ +│ - Binds & validates request body │ +│ - Calls service layer │ +│ - Maps domain errors → httperror │ +│ - Returns httpresponse.OK(w, r, response) │ +└────────────┬────────────────────────┬───────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ service/preferences.go │ +│ - GetPreferences: repo.Get → defaults if not found │ +│ - UpdatePreferences: repo.Get → merge → validate → │ +│ repo.Upsert │ +└────────────┬────────────────────────┬───────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ port/preferences.go (interface) │ +│ - Get(ctx, userID) → (*UserPreferences, error) │ +│ - Upsert(ctx, *UserPreferences) → error │ +└────────────┬────────────────────────┬───────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ adapter/postgres/preferences.go │ +│ - Get: SELECT ... WHERE user_id = $1 │ +│ - Upsert: INSERT ... ON CONFLICT (user_id) DO UPDATE │ +│ Uses *database.Pool (sqlx) │ +└────────────┬────────────────────────┬───────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ PostgreSQL: user_preferences table │ +│ (user_id UUID PK, preferences JSONB, timestamps) │ +└─────────────────────────────────────────────────────────┘ +``` + +## Error Handling Strategy + +### Domain Errors + +```go +var ( + ErrInvalidTheme = errors.New("invalid theme: must be light, dark, or system") + ErrInvalidLanguage = errors.New("invalid language: must be non-empty") + ErrInvalidDigest = errors.New("invalid digest: must be daily, weekly, or never") +) +``` + +### Error Mapping (handler layer) + +| Domain Error | HTTP Error | Status | +|-------------|-----------|--------| +| `ErrInvalidTheme` | `httperror.BadRequest(msg)` | 400 | +| `ErrInvalidLanguage` | `httperror.BadRequest(msg)` | 400 | +| `ErrInvalidDigest` | `httperror.BadRequest(msg)` | 400 | +| Invalid UUID (user_id) | `httperror.BadRequest("invalid user_id format")` | 400 | +| Request body parse error | Handled by `app.BindAndValidate()` | 400 | +| Database connection error | Unhandled → `app.Wrap()` returns 500 | 500 | + +### Key Behaviors + +- **GET for unknown user:** Returns 200 with default preferences (not 404). No error. +- **PUT with empty body:** Returns 400 via `app.BindAndValidate()` (the `preferences` field is `validate:"required"`). +- **PUT with partial preferences:** Merges with existing. Only validates provided fields. +- **Database errors:** Bubble up as raw errors. `app.Wrap()` converts them to 500. + +## Security Considerations + +- **No authentication required for this feature** (per spec: auth is out of scope). Routes are public. Auth middleware can be added later via route group. +- **User ID from URL path, not session:** Any caller can read/write any user's preferences. This is intentional — the preferences service is a backend store, not a user-facing endpoint. Upstream services/gateways enforce authorization. +- **Input validation:** All preference values are validated against allowlists. No arbitrary string storage. +- **SQL injection prevention:** All queries use parameterized placeholders (`$1`, `$2`). JSONB values are marshaled by `encoding/json` and passed as parameters. +- **Request body size:** Limited by the framework's default max body size. +- **No sensitive data:** Preferences (theme, language, notifications) contain no PII or secrets. +- **Strict JSON binding:** Unknown fields in the request body are rejected to prevent confusion. + +## Performance Considerations + +- **Single row per user:** O(1) lookup by UUID primary key. No joins, no pagination needed. +- **JSONB column:** PostgreSQL JSONB is compact and efficient for reads. No need for GIN indexes — we query by `user_id` PK only, never by preference content. +- **No caching layer:** For MVP, direct database reads are sufficient. The query is a simple PK lookup. If latency becomes an issue, an in-memory or Redis cache can be added as a separate adapter behind the same port interface. +- **Upsert atomicity:** `INSERT ... ON CONFLICT DO UPDATE` is a single atomic statement. No race conditions on concurrent writes for the same user. +- **JSONB merge in application layer:** The merge happens in Go, not in SQL. This keeps the SQL simple and the merge logic testable. The full merged JSONB is written back. For this data size (~200 bytes of JSON), this is efficient. +- **Expected load:** Low. Preferences are read on session start and written on settings change. Well within single-instance PostgreSQL capacity. + +## Migration / Rollout Plan + +### Step 1: Remove Example Scaffolding + +Delete all example-related files: +- `internal/domain/example.go` +- `internal/port/example.go` +- `internal/service/example.go`, `example_test.go` +- `internal/api/handlers/example.go`, `example_test.go` +- `internal/adapter/memory/example.go` + +Remove example routes and OpenAPI definitions from `routes.go` and `spec.go`. + +### Step 2: Add Preferences Domain + +Create new files following the same directory structure: +- `internal/domain/preferences.go` — entity, validation, defaults +- `internal/domain/errors.go` — updated with preference-specific errors +- `internal/port/preferences.go` — `PreferencesRepository` interface +- `internal/service/preferences.go` — `PreferencesService` with `GetPreferences` and `UpdatePreferences` +- `internal/service/preferences_test.go` — unit tests with mock repository +- `internal/api/handlers/preferences.go` — HTTP handlers +- `internal/api/handlers/preferences_test.go` — handler tests + +### Step 3: Add PostgreSQL Adapter + +- `internal/adapter/postgres/preferences.go` — implements `PreferencesRepository` +- `migrations/001_create_user_preferences.sql` — table creation + +### Step 4: Update Wiring + +- `internal/api/routes.go` — register new preference routes +- `internal/api/spec.go` — new OpenAPI definitions for preference endpoints +- `cmd/server/main.go` — connect to PostgreSQL, run migrations, wire PostgreSQL adapter + +### Step 5: Verify + +- All unit tests pass (`go test -v ./...`) +- OpenAPI spec exports correctly (`--export-openapi` flag) +- Health endpoint still works +- Manual verification against acceptance criteria + +### Backward Compatibility + +This is a **breaking replacement** of the example scaffolding, which was never a production API. No backward compatibility is needed. The example endpoints (`/examples`, `/examples/{id}`) are removed entirely. + +## File Inventory + +| Action | File | +|--------|------| +| **Delete** | `internal/domain/example.go` | +| **Delete** | `internal/port/example.go` | +| **Delete** | `internal/service/example.go` | +| **Delete** | `internal/service/example_test.go` | +| **Delete** | `internal/api/handlers/example.go` | +| **Delete** | `internal/api/handlers/example_test.go` | +| **Delete** | `internal/adapter/memory/example.go` | +| **Modify** | `internal/domain/errors.go` | +| **Modify** | `internal/api/routes.go` | +| **Modify** | `internal/api/spec.go` | +| **Modify** | `cmd/server/main.go` | +| **Create** | `internal/domain/preferences.go` | +| **Create** | `internal/port/preferences.go` | +| **Create** | `internal/service/preferences.go` | +| **Create** | `internal/service/preferences_test.go` | +| **Create** | `internal/api/handlers/preferences.go` | +| **Create** | `internal/api/handlers/preferences_test.go` | +| **Create** | `internal/adapter/postgres/preferences.go` | +| **Create** | `migrations/001_create_user_preferences.sql` | diff --git a/.sdlc/features/user-preferences/manifest.yaml b/.sdlc/features/user-preferences/manifest.yaml index 16c0656..1b509ff 100644 --- a/.sdlc/features/user-preferences/manifest.yaml +++ b/.sdlc/features/user-preferences/manifest.yaml @@ -13,7 +13,7 @@ artifacts: status: pending path: audit.md design: - status: pending + status: draft path: design.md qa_plan: status: pending