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
b2e88a92a3
commit
8e08dbd822
406
.sdlc/features/user-preferences/design.md
Normal file
406
.sdlc/features/user-preferences/design.md
Normal file
@ -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.
|
||||
@ -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