6.8 KiB
Feature: User Preferences API
Problem Statement
Users need a way to persist and retrieve their application preferences (theme, language, notification settings) so that their experience is consistent across sessions and devices. Currently there is no preferences storage — the preferences-api service exists as a skeleton with only the example CRUD scaffold. This feature replaces the example entity with a real user preferences system.
User Stories
- As an authenticated user, I want to save my theme preference so that the UI renders in my chosen theme across sessions.
- As an authenticated user, I want to save my language preference so that content is displayed in my preferred language.
- As an authenticated user, I want to configure my notification settings so that I only receive the alerts I care about.
- As an authenticated user, I want to retrieve all my preferences in a single call so that the frontend can initialize quickly.
- As an API consumer, I want to update individual preference keys without overwriting unrelated settings.
API Design
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/preferences-api/preferences/{user_id} |
Required | Retrieve all preferences for a user |
| PUT | /api/preferences-api/preferences/{user_id} |
Required | Create or update preferences for a user (merge semantics) |
Request / Response
GET /api/preferences-api/preferences/{user_id}
Response 200 OK:
{
"data": {
"user_id": "uuid",
"preferences": {
"theme": "dark",
"language": "en",
"notifications": {
"email": true,
"push": false,
"digest": "weekly"
}
},
"updated_at": "2026-02-08T00:00:00Z"
},
"meta": { "request_id": "...", "timestamp": "..." }
}
Response 404 Not Found (no preferences saved yet):
{
"error": { "code": "NOT_FOUND", "message": "preferences not found" },
"meta": { "request_id": "...", "timestamp": "..." }
}
PUT /api/preferences-api/preferences/{user_id}
Request body:
{
"preferences": {
"theme": "light",
"notifications": {
"email": false
}
}
}
Response 200 OK (returns merged result):
{
"data": {
"user_id": "uuid",
"preferences": {
"theme": "light",
"language": "en",
"notifications": {
"email": false,
"push": false,
"digest": "weekly"
}
},
"updated_at": "2026-02-08T00:00:01Z"
},
"meta": { "request_id": "...", "timestamp": "..." }
}
Merge Semantics
PUT performs a shallow merge at the top-level keys (theme, language, notifications). Nested objects like notifications are replaced entirely when provided. This keeps behavior predictable without requiring JSON Patch complexity.
Acceptance Criteria
- GET
/api/preferences-api/preferences/{user_id}returns200with stored preferences - GET returns
404when no preferences exist for the user - PUT
/api/preferences-api/preferences/{user_id}creates preferences if none exist (upsert) - PUT merges provided keys with existing preferences (shallow merge)
- PUT returns
200with the full merged preference set - PUT validates that
preferencesfield is present and is a JSON object - PUT rejects unknown top-level preference keys with
400 Bad Request - Both endpoints require authentication via
auth.Middleware() - Authenticated user can only access their own preferences (user_id in path must match token subject), unless they have an admin role
user_idpath parameter is validated as a UUID- Preferences are persisted in-memory via the existing adapter pattern (database adapter deferred)
- OpenAPI spec documents both endpoints with schemas, examples, and error responses
- Domain model defines allowed preference keys and validation rules
- Handler tests cover success paths, validation errors, auth failures, and not-found cases
- Service tests cover merge logic, create-on-first-PUT, and authorization checks
- All existing
examplescaffold code is removed and replaced with preferences code
Data Model
Domain Entity: Preference
type UserID string
type Preferences struct {
UserID UserID
Theme string // "light", "dark", "system"
Language string // ISO 639-1 code: "en", "es", "fr", etc.
Notifications NotificationSettings
UpdatedAt time.Time
}
type NotificationSettings struct {
Email bool
Push bool
Digest string // "daily", "weekly", "never"
}
Allowed Values
| Key | Type | Allowed Values | Default |
|---|---|---|---|
theme |
string | light, dark, system |
system |
language |
string | ISO 639-1 codes | en |
notifications.email |
bool | true, false |
true |
notifications.push |
bool | true, false |
true |
notifications.digest |
string | daily, weekly, never |
weekly |
Technical Constraints
- Must follow the existing hexagonal architecture: domain → service → port → adapter
- Must use
app.Wrap(),app.BindAndValidate(),httpresponse.*,httperror.*patterns - Must use
auth.Middleware()for protected routes - In-memory adapter for initial implementation (matches existing pattern); database migration deferred to a follow-up feature
- Preference values must be validated against allowed values in the domain layer
- OpenAPI spec must be updated to replace example endpoints with preference endpoints
- Route base path remains
/api/preferences-api
Dependencies
pkg/app— handler wrapping, request bindingpkg/auth— JWT middleware, context user extractionpkg/httperror— typed HTTP errorspkg/httpresponse— response envelopepkg/httpvalidation— struct validationpkg/openapi— spec builderpkg/logging— structured logging- Existing
preferences-apiservice skeleton (to be modified in-place)
Out of Scope
- Database persistence (PostgreSQL adapter) — separate follow-up feature
- Bulk preference operations across multiple users
- Preference history / audit log
- Preference defaults management API (defaults are hardcoded in domain)
- Frontend integration (consuming the API from apps/)
- Rate limiting
- Preference change webhooks / event publishing
Open Questions
- Authorization model: Should any user be able to read another user's preferences, or is it strictly own-user-only? The spec assumes own-user + admin override, but this needs confirmation.
- Additional preference keys: Are
theme,language, andnotificationsthe complete set, or should the schema be extensible for future keys without code changes? - Default preferences: When GET returns 404, should we instead return a
200with default values? This simplifies the frontend but changes the semantic contract.