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
257ad77471
commit
100b3c4035
648
.sdlc/features/user-preferences/design.md
Normal file
648
.sdlc/features/user-preferences/design.md
Normal file
@ -0,0 +1,648 @@
|
||||
# Design: User Preferences API
|
||||
|
||||
## Architecture Approach
|
||||
|
||||
The feature replaces the existing example/scaffold CRUD resource in `preferences-api` with a real user preferences domain. The hexagonal architecture already in place is preserved — only the inner layers change.
|
||||
|
||||
**What changes:**
|
||||
- **Domain layer** — New `UserPreferences` entity with validation, replacing `Example`
|
||||
- **Port layer** — New `PreferencesRepository` interface, replacing `ExampleRepository`
|
||||
- **Service layer** — New `PreferencesService` with get/upsert logic, replacing `ExampleService`
|
||||
- **Adapter layer** — New PostgreSQL adapter (replacing in-memory `Example` adapter)
|
||||
- **Handler layer** — Two new handlers (GET, PUT), replacing five example handlers
|
||||
- **Routes** — New authenticated route group at `/api/preferences-api/preferences/{user_id}`
|
||||
- **OpenAPI spec** — Updated with preferences schemas and endpoints
|
||||
- **Migrations** — New SQL migration for `user_preferences` table
|
||||
- **main.go** — Updated to wire database pool and new dependencies
|
||||
|
||||
**What is removed:**
|
||||
- All `example` domain, port, service, adapter, handler, and test code
|
||||
- The in-memory adapter (production uses PostgreSQL)
|
||||
|
||||
**What is unchanged:**
|
||||
- Health check handler and route
|
||||
- `config/config.go` (already supports DATABASE_URL, AUTH_ENABLED, JWT_SECRET)
|
||||
- Dockerfile, Makefile, component.yaml, go.mod structure
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### New Domain Types
|
||||
|
||||
```go
|
||||
// domain/preferences.go
|
||||
|
||||
type UserID string
|
||||
|
||||
type NotificationPreferences struct {
|
||||
Email bool
|
||||
Push bool
|
||||
SMS bool
|
||||
}
|
||||
|
||||
type Preferences struct {
|
||||
Theme string
|
||||
Language string
|
||||
Notifications NotificationPreferences
|
||||
}
|
||||
|
||||
type UserPreferences struct {
|
||||
UserID UserID
|
||||
Preferences Preferences
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
### Default Values
|
||||
|
||||
When no preferences exist for a user, the service returns defaults:
|
||||
|
||||
| Key | Default |
|
||||
|-----|---------|
|
||||
| `theme` | `"system"` |
|
||||
| `language` | `"en"` |
|
||||
| `notifications.email` | `true` |
|
||||
| `notifications.push` | `true` |
|
||||
| `notifications.sms` | `false` |
|
||||
|
||||
Defaults are defined as a function in the domain layer (`DefaultPreferences()`) — the single source of truth.
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Table: `user_preferences`**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
preferences JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Design decisions:**
|
||||
- `user_id` as `TEXT PRIMARY KEY` — no UUID type constraint; IDs come from the auth system
|
||||
- `preferences` as `JSONB` — single document per user, supporting the spec's extensibility requirement (unknown keys preserved)
|
||||
- `created_at` included for operational debugging even though it's not exposed in the API
|
||||
- No foreign key to a users table — preferences-api is a standalone service
|
||||
|
||||
### Migration File
|
||||
|
||||
`migrations/001_create_user_preferences.sql`
|
||||
|
||||
Single idempotent migration using `IF NOT EXISTS`. Embedded via `//go:embed` per project convention.
|
||||
|
||||
## API Changes
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/api/preferences-api/preferences/{user_id}` | Required | Get preferences (returns defaults if none saved) |
|
||||
| `PUT` | `/api/preferences-api/preferences/{user_id}` | Required | Create or replace preferences |
|
||||
|
||||
### GET `/api/preferences-api/preferences/{user_id}`
|
||||
|
||||
**Authorization:** Authenticated user's ID must match `{user_id}`, else 403.
|
||||
|
||||
**Behavior:**
|
||||
1. Extract `user_id` from URL path
|
||||
2. Verify authenticated user matches `user_id`
|
||||
3. Query database for preferences
|
||||
4. If no row exists, return default preferences
|
||||
5. Return response with `{data, meta}` envelope
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"user_id": "usr_abc123",
|
||||
"preferences": {
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"notifications": {
|
||||
"email": true,
|
||||
"push": true,
|
||||
"sms": false
|
||||
}
|
||||
},
|
||||
"updated_at": "2026-02-08T10:30:00Z"
|
||||
},
|
||||
"meta": {
|
||||
"request_id": "...",
|
||||
"timestamp": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When no preferences saved (200 with defaults):**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"user_id": "usr_abc123",
|
||||
"preferences": {
|
||||
"theme": "system",
|
||||
"language": "en",
|
||||
"notifications": {
|
||||
"email": true,
|
||||
"push": true,
|
||||
"sms": false
|
||||
}
|
||||
},
|
||||
"updated_at": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"meta": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The `updated_at` zero value signals "never saved". Alternatively, it could be omitted when returning defaults — but including it keeps the response shape consistent.
|
||||
|
||||
### PUT `/api/preferences-api/preferences/{user_id}`
|
||||
|
||||
**Authorization:** Authenticated user's ID must match `{user_id}`, else 403.
|
||||
|
||||
**Behavior:**
|
||||
1. Extract `user_id` from URL path
|
||||
2. Verify authenticated user matches `user_id`
|
||||
3. Bind and validate request body
|
||||
4. Run domain validation on known keys
|
||||
5. Upsert into database (INSERT ON CONFLICT UPDATE)
|
||||
6. Return saved preferences with `{data, meta}` envelope
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"preferences": {
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"notifications": {
|
||||
"email": true,
|
||||
"push": true,
|
||||
"sms": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Validation rules (domain layer):**
|
||||
- `theme`: Must be one of `"light"`, `"dark"`, `"system"` — if present
|
||||
- `language`: Max 10 characters — if present
|
||||
- Unknown top-level keys in `preferences`: preserved (per spec extensibility requirement)
|
||||
- `notifications` sub-keys: booleans, no special validation needed (Go zero-value is `false`)
|
||||
|
||||
**Response (200):**
|
||||
Same shape as GET response, with the just-saved data and current timestamp.
|
||||
|
||||
**Error (400):**
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "invalid theme: must be one of light, dark, system"
|
||||
},
|
||||
"meta": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Component Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ HTTP Layer │
|
||||
│ │
|
||||
│ auth.Middleware() ──▶ handlers.Preferences │
|
||||
│ │ │
|
||||
│ GET /preferences/{user_id} ──▶ Get() ──▶ httpresponse.OK() │
|
||||
│ PUT /preferences/{user_id} ──▶ Put() ──▶ httpresponse.OK() │
|
||||
│ │ │
|
||||
│ mapDomainError() ──▶ httperror.* │
|
||||
└────────────────┬─────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────▼─────────────────────────────────────────────────┐
|
||||
│ Service Layer │
|
||||
│ │
|
||||
│ PreferencesService │
|
||||
│ ├── GetPreferences(ctx, userID) → *UserPreferences, error │
|
||||
│ │ └── returns defaults if repo returns ErrNotFound │
|
||||
│ └── SetPreferences(ctx, userID, prefs) → *UserPreferences, err│
|
||||
│ └── validates, then upserts via repo │
|
||||
└────────────────┬─────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────▼─────────────────────────────────────────────────┐
|
||||
│ Port Layer (Interface) │
|
||||
│ │
|
||||
│ PreferencesRepository │
|
||||
│ ├── Get(ctx, userID) → *UserPreferences, error │
|
||||
│ └── Upsert(ctx, prefs *UserPreferences) → error │
|
||||
└────────────────┬─────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────▼─────────────────────────────────────────────────┐
|
||||
│ Adapter Layer (PostgreSQL) │
|
||||
│ │
|
||||
│ postgres.PreferencesRepository │
|
||||
│ ├── Get() → SELECT ... WHERE user_id = $1 │
|
||||
│ └── Upsert() → INSERT ... ON CONFLICT (user_id) │
|
||||
│ DO UPDATE SET preferences = $2, updated_at = $3│
|
||||
│ │
|
||||
│ Uses: database.Pool.DB (*sqlx.DB) │
|
||||
└────────────────┬─────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────▼─────────────────────────────────────────────────┐
|
||||
│ Domain Layer (Pure) │
|
||||
│ │
|
||||
│ UserPreferences, Preferences, NotificationPreferences │
|
||||
│ DefaultPreferences() │
|
||||
│ Validate() → error │
|
||||
│ ErrInvalidTheme, ErrInvalidLanguage, ErrForbidden │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Layer-by-Layer Implementation Details
|
||||
|
||||
### Domain (`internal/domain/`)
|
||||
|
||||
**Files to create:**
|
||||
- `preferences.go` — Types, constructors, `DefaultPreferences()`, `Validate()`
|
||||
- `errors.go` — Updated with `ErrInvalidTheme`, `ErrInvalidLanguage`, `ErrForbidden`, `ErrPreferencesNotFound`
|
||||
|
||||
**Files to delete:**
|
||||
- `example.go`
|
||||
|
||||
**Validation logic in `Preferences.Validate()`:**
|
||||
```go
|
||||
func (p *Preferences) Validate() error {
|
||||
if p.Theme != "" {
|
||||
switch p.Theme {
|
||||
case "light", "dark", "system":
|
||||
// valid
|
||||
default:
|
||||
return ErrInvalidTheme
|
||||
}
|
||||
}
|
||||
if len([]rune(p.Language)) > 10 {
|
||||
return ErrInvalidLanguage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Unknown keys: The spec says unknown keys are preserved but not validated. Since we store the full JSON document in a JSONB column, unknown keys survive naturally. The `Preferences` struct uses a map for extensibility:
|
||||
|
||||
```go
|
||||
type Preferences struct {
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
Notifications NotificationPreferences `json:"notifications"`
|
||||
Extra map[string]any `json:"-"` // captured via custom marshal/unmarshal
|
||||
}
|
||||
```
|
||||
|
||||
A custom `UnmarshalJSON`/`MarshalJSON` pair on `Preferences` decodes known fields into struct fields and captures everything else into `Extra`. On marshal, known fields and `Extra` are merged back. This preserves unknown keys through the round-trip without schema migrations.
|
||||
|
||||
### Port (`internal/port/`)
|
||||
|
||||
**Files to create:**
|
||||
- `preferences.go` — `PreferencesRepository` interface
|
||||
|
||||
**Files to delete:**
|
||||
- `example.go`
|
||||
|
||||
```go
|
||||
type PreferencesRepository interface {
|
||||
Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
|
||||
Upsert(ctx context.Context, prefs *domain.UserPreferences) error
|
||||
}
|
||||
```
|
||||
|
||||
Only two methods needed — no List, Delete, or ExistsByName. The simple interface keeps the adapter thin.
|
||||
|
||||
### Service (`internal/service/`)
|
||||
|
||||
**Files to create:**
|
||||
- `preferences.go` — `PreferencesService`
|
||||
- `preferences_test.go` — Unit tests
|
||||
|
||||
**Files to delete:**
|
||||
- `example.go`
|
||||
- `example_test.go`
|
||||
|
||||
```go
|
||||
type PreferencesService struct {
|
||||
repo port.PreferencesRepository
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
func (s *PreferencesService) GetPreferences(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error)
|
||||
func (s *PreferencesService) SetPreferences(ctx context.Context, userID domain.UserID, prefs domain.Preferences) (*domain.UserPreferences, error)
|
||||
```
|
||||
|
||||
**GetPreferences logic:**
|
||||
1. Call `repo.Get(ctx, userID)`
|
||||
2. If `ErrPreferencesNotFound`, return `DefaultPreferences()` with the given `userID`
|
||||
3. Otherwise return the stored preferences
|
||||
|
||||
**SetPreferences logic:**
|
||||
1. Call `prefs.Validate()` — return domain error if invalid
|
||||
2. Build `UserPreferences{UserID: userID, Preferences: prefs, UpdatedAt: time.Now().UTC()}`
|
||||
3. Call `repo.Upsert(ctx, &userPrefs)`
|
||||
4. Return the saved preferences
|
||||
|
||||
Authorization (checking user_id matches authenticated user) is done in the **handler layer**, not here — the service layer doesn't know about HTTP or JWT. This follows the existing pattern where `mapDomainError()` in handlers maps domain errors to HTTP errors.
|
||||
|
||||
### Adapter (`internal/adapter/postgres/`)
|
||||
|
||||
**Files to create:**
|
||||
- `preferences.go` — PostgreSQL implementation of `PreferencesRepository`
|
||||
|
||||
**Files to delete:**
|
||||
- `adapter/memory/example.go`
|
||||
|
||||
```go
|
||||
type PreferencesRepository struct {
|
||||
db *sqlx.DB
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
func (r *PreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) {
|
||||
// SELECT user_id, preferences, updated_at FROM user_preferences WHERE user_id = $1
|
||||
// If no rows: return domain.ErrPreferencesNotFound
|
||||
// Unmarshal JSONB into domain.Preferences
|
||||
}
|
||||
|
||||
func (r *PreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error {
|
||||
// INSERT INTO user_preferences (user_id, preferences, updated_at)
|
||||
// VALUES ($1, $2, $3)
|
||||
// ON CONFLICT (user_id) DO UPDATE SET preferences = $2, updated_at = $3
|
||||
// Marshal domain.Preferences to JSON for JSONB column
|
||||
}
|
||||
```
|
||||
|
||||
### Handlers (`internal/api/handlers/`)
|
||||
|
||||
**Files to create:**
|
||||
- `preferences.go` — GET and PUT handlers
|
||||
- `preferences_test.go` — Handler tests
|
||||
|
||||
**Files to delete:**
|
||||
- `example.go`
|
||||
- `example_test.go`
|
||||
|
||||
```go
|
||||
type Preferences struct {
|
||||
svc *service.PreferencesService
|
||||
logger *logging.Logger
|
||||
}
|
||||
```
|
||||
|
||||
**Request/Response types:**
|
||||
|
||||
```go
|
||||
type PutPreferencesRequest struct {
|
||||
Preferences PreferencesPayload `json:"preferences" validate:"required"`
|
||||
}
|
||||
|
||||
type PreferencesPayload struct {
|
||||
Theme string `json:"theme,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Notifications *NotificationPreferencesPayload `json:"notifications,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationPreferencesPayload struct {
|
||||
Email bool `json:"email"`
|
||||
Push bool `json:"push"`
|
||||
SMS bool `json:"sms"`
|
||||
}
|
||||
|
||||
type PreferencesResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Preferences PreferencesPayload `json:"preferences"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
**Handler: Get**
|
||||
```go
|
||||
func (h *Preferences) Get(w http.ResponseWriter, r *http.Request) error {
|
||||
userID := chi.URLParam(r, "user_id")
|
||||
|
||||
// Authorization check
|
||||
authUser := auth.GetUser(r.Context())
|
||||
if authUser.ID != userID {
|
||||
return httperror.Forbidden("access denied: can only access own preferences")
|
||||
}
|
||||
|
||||
prefs, err := h.svc.GetPreferences(r.Context(), domain.UserID(userID))
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
return httpresponse.OK(w, r, toResponse(prefs))
|
||||
}
|
||||
```
|
||||
|
||||
**Handler: Put**
|
||||
```go
|
||||
func (h *Preferences) Put(w http.ResponseWriter, r *http.Request) error {
|
||||
userID := chi.URLParam(r, "user_id")
|
||||
|
||||
// Authorization check
|
||||
authUser := auth.GetUser(r.Context())
|
||||
if authUser.ID != userID {
|
||||
return httperror.Forbidden("access denied: can only modify own preferences")
|
||||
}
|
||||
|
||||
var req PutPreferencesRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prefs, err := h.svc.SetPreferences(r.Context(), domain.UserID(userID), toDomain(req.Preferences))
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
return httpresponse.OK(w, r, toResponse(prefs))
|
||||
}
|
||||
```
|
||||
|
||||
**Error mapping:**
|
||||
```go
|
||||
func mapDomainError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrInvalidTheme):
|
||||
return httperror.BadRequest("invalid theme: must be one of light, dark, system")
|
||||
case errors.Is(err, domain.ErrInvalidLanguage):
|
||||
return httperror.BadRequest("invalid language: must be at most 10 characters")
|
||||
default:
|
||||
return err // app.Wrap() will handle as 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Routes (`internal/api/routes.go`)
|
||||
|
||||
Replace example routes with:
|
||||
|
||||
```go
|
||||
func RegisterRoutes(application *app.App, prefsSvc *service.PreferencesService, authCfg config.Config) {
|
||||
logger := application.Logger()
|
||||
|
||||
healthHandler := &handlers.Health{Logger: logger}
|
||||
prefsHandler := handlers.NewPreferences(prefsSvc, logger)
|
||||
|
||||
r := application.Router()
|
||||
|
||||
// Public routes
|
||||
r.Get("/api/preferences-api/health", healthHandler.Check)
|
||||
|
||||
// Protected routes — auth required for all preference endpoints
|
||||
r.Route("/api/preferences-api/preferences", func(r chi.Router) {
|
||||
if authCfg.AuthEnabled {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(authCfg.JWTSecret),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
r.Get("/{user_id}", app.Wrap(prefsHandler.Get))
|
||||
r.Put("/{user_id}", app.Wrap(prefsHandler.Put))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAPI Spec (`internal/api/spec.go`)
|
||||
|
||||
Update to define:
|
||||
- Schema: `Preferences` (theme, language, notifications)
|
||||
- Schema: `NotificationPreferences` (email, push, sms)
|
||||
- Schema: `UserPreferencesResponse` (user_id, preferences, updated_at)
|
||||
- Schema: `PutPreferencesRequest` (preferences object)
|
||||
- Path: `GET /api/preferences-api/preferences/{user_id}` with bearer auth, 200/403 responses
|
||||
- Path: `PUT /api/preferences-api/preferences/{user_id}` with bearer auth, 200/400/403 responses
|
||||
- `{user_id}` path parameter
|
||||
|
||||
### main.go (`cmd/server/main.go`)
|
||||
|
||||
Update to:
|
||||
1. Connect to PostgreSQL using `database.Connect()`
|
||||
2. Run migrations using `database.MustRunMigrations()`
|
||||
3. Create `postgres.PreferencesRepository` with `pool.DB`
|
||||
4. Create `PreferencesService` with the repo
|
||||
5. Register routes with service and auth config
|
||||
6. Register `pool.Close()` on shutdown
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
| Scenario | Layer | Error | HTTP Status |
|
||||
|----------|-------|-------|-------------|
|
||||
| Invalid theme value | Domain | `ErrInvalidTheme` | 400 Bad Request |
|
||||
| Language too long | Domain | `ErrInvalidLanguage` | 400 Bad Request |
|
||||
| Malformed JSON body | Handler (BindAndValidate) | Automatic | 400 Bad Request |
|
||||
| Missing `preferences` field | Handler (BindAndValidate) | Validation | 400 Bad Request |
|
||||
| User accessing another user's prefs | Handler | `httperror.Forbidden` | 403 Forbidden |
|
||||
| No preferences saved yet | Service | (returns defaults) | 200 OK |
|
||||
| Database connection failure | Adapter | raw error | 500 Internal |
|
||||
| Database query failure | Adapter | raw error | 500 Internal |
|
||||
|
||||
**Key decisions:**
|
||||
- GET never returns 404 — missing preferences yield defaults. This simplifies the frontend (no special "first time" flow).
|
||||
- Authorization is checked in handlers before any service call, failing fast with 403.
|
||||
- Domain validation errors are specific and mapped to descriptive 400 messages.
|
||||
- Database errors bubble up as raw errors, caught by `app.Wrap()` and returned as 500 with the error logged server-side (not leaked to client).
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
- Both endpoints require `auth.Middleware()`. Unauthenticated requests receive 401.
|
||||
- JWT validation via `pkg/auth.NewJWTValidator` with HMAC secret from config.
|
||||
|
||||
### Authorization
|
||||
- **Owner-only access**: The `{user_id}` in the URL path must match `auth.GetUser(ctx).ID`. This is checked in the handler before calling the service layer.
|
||||
- No admin override endpoint (out of scope per spec).
|
||||
|
||||
### Input Validation
|
||||
- Request body bound and validated via `app.BindAndValidate()` — rejects malformed JSON and missing required fields.
|
||||
- Domain-level validation for `theme` (enum) and `language` (max length).
|
||||
- JSONB column stores raw preferences — unknown keys preserved but size is bounded by PostgreSQL's TOAST limit (~1GB). For practical limits, the handler can check `Content-Length` against a reasonable threshold (e.g., 64KB). This addresses the spec's open question about preference size limits.
|
||||
|
||||
### Data Boundaries
|
||||
- Users can only read/write their own preferences — no cross-user data access.
|
||||
- Error responses never leak internal details (database errors, stack traces).
|
||||
- The `preferences` JSONB column is treated as opaque by the database — no SQL injection vector.
|
||||
|
||||
### SQL Injection
|
||||
- All queries use parameterized statements (`$1`, `$2`) via `sqlx` — no string concatenation.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Expected Load
|
||||
- Read-heavy workload: preferences fetched on every page load / session start.
|
||||
- Writes are infrequent: users change preferences rarely.
|
||||
|
||||
### Query Performance
|
||||
- **GET**: Single-row lookup by primary key (`user_id`) — O(1) with B-tree index.
|
||||
- **PUT**: Upsert by primary key — O(1).
|
||||
- No need for additional indexes. The primary key index is sufficient.
|
||||
|
||||
### Caching Strategy
|
||||
- **Not implemented in this iteration** (out of scope). The single-row PK lookup is fast enough.
|
||||
- If needed later: HTTP `Cache-Control` headers or an in-process cache with short TTL.
|
||||
|
||||
### Connection Pooling
|
||||
- Uses `database.Pool` with configurable pool size (default: 25 open, 5 idle). Adequate for preferences traffic.
|
||||
|
||||
### Payload Size
|
||||
- Preferences JSON is small (< 1KB typical). No pagination or streaming needed.
|
||||
|
||||
## Migration / Rollout Plan
|
||||
|
||||
### Step 1: Database Migration
|
||||
The `001_create_user_preferences.sql` migration runs on startup via `database.MustRunMigrations()`. It uses `CREATE TABLE IF NOT EXISTS` for idempotency. No existing tables are modified or dropped.
|
||||
|
||||
### Step 2: Code Deployment
|
||||
The service is fully backward-compatible at the infrastructure level:
|
||||
- Same port (8001)
|
||||
- Same health check path (`/api/preferences-api/health`)
|
||||
- Example endpoints are removed, but nothing depends on them (they're scaffold)
|
||||
|
||||
### Step 3: Verification
|
||||
- Health check confirms service starts and database is reachable
|
||||
- GET returns default preferences for any authenticated user (no data seeding needed)
|
||||
- PUT creates preferences on first save
|
||||
|
||||
### Rollback
|
||||
- Revert to previous deployment. The `user_preferences` table can remain — it won't interfere with the example scaffold code.
|
||||
- No destructive migrations — forward-only table creation.
|
||||
|
||||
## Open Questions Resolution
|
||||
|
||||
From the spec:
|
||||
|
||||
1. **Authorization model**: Design uses `auth.GetUser(ctx).ID` — the `User.ID` field populated by JWT validation. This maps to the `sub` claim or `uid` custom claim (both supported by `pkg/auth.JWTClaims`). No changes to auth package needed.
|
||||
|
||||
2. **Unknown preference keys**: Preserved via custom JSON marshaling on the `Preferences` struct. Known keys are validated; unknown keys pass through to JSONB storage unchanged.
|
||||
|
||||
3. **Preference size limit**: Addressed by checking request `Content-Length` in the handler (64KB default). This prevents abuse without requiring schema changes.
|
||||
|
||||
## File Change Summary
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| **Create** | `migrations/001_create_user_preferences.sql` |
|
||||
| **Create** | `internal/domain/preferences.go` |
|
||||
| **Create** | `internal/port/preferences.go` |
|
||||
| **Create** | `internal/service/preferences.go` |
|
||||
| **Create** | `internal/service/preferences_test.go` |
|
||||
| **Create** | `internal/adapter/postgres/preferences.go` |
|
||||
| **Create** | `internal/api/handlers/preferences.go` |
|
||||
| **Create** | `internal/api/handlers/preferences_test.go` |
|
||||
| **Modify** | `internal/domain/errors.go` |
|
||||
| **Modify** | `internal/api/routes.go` |
|
||||
| **Modify** | `internal/api/spec.go` |
|
||||
| **Modify** | `cmd/server/main.go` |
|
||||
| **Delete** | `internal/domain/example.go` |
|
||||
| **Delete** | `internal/port/example.go` |
|
||||
| **Delete** | `internal/service/example.go` |
|
||||
| **Delete** | `internal/service/example_test.go` |
|
||||
| **Delete** | `internal/adapter/memory/example.go` |
|
||||
| **Delete** | `internal/api/handlers/example.go` |
|
||||
| **Delete** | `internal/api/handlers/example_test.go` |
|
||||
@ -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