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
|
status: pending
|
||||||
path: audit.md
|
path: audit.md
|
||||||
design:
|
design:
|
||||||
status: pending
|
status: draft
|
||||||
path: design.md
|
path: design.md
|
||||||
qa_plan:
|
qa_plan:
|
||||||
status: pending
|
status: pending
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user