build: /design-feature user-preferences
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
208033482e
commit
2da48d43f8
449
.sdlc/features/user-preferences/design.md
Normal file
449
.sdlc/features/user-preferences/design.md
Normal file
@ -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) |
|
||||
@ -10,7 +10,7 @@ artifacts:
|
||||
status: pending
|
||||
path: audit.md
|
||||
design:
|
||||
status: pending
|
||||
status: draft
|
||||
path: design.md
|
||||
qa_plan:
|
||||
status: pending
|
||||
|
||||
Loading…
Reference in New Issue
Block a user