UserPreference
src/UserPreference/ manages per-user UI and playback preferences: accent color, sidebar configuration, audio equalizer profiles, layout preferences, player preferences, and theme mood. These are lightweight settings that control frontend appearance and playback behavior, stored per-user. The context also owns the per-user half of the settings mechanism: the user_settings store, the user settings API, and the contract through which other contexts read and change a user's settings.
The preference subsystems do not use CQRS: their controllers call port interfaces directly for both reads and writes. User settings are written through SetUserSettingCommand and ResetUserSettingCommand and read through UserSettingsReader.
Subsystems
The context is organized into seven preference subsystems, each with its own controller, port, and persistence entity, and the user settings store:
| Subsystem | Purpose | Versioned |
|---|---|---|
| AccentColor | UI accent color, any string of up to 32 characters (default violet) |
No |
| SidebarConfig | Per-media-type sidebar section ordering and visibility | No |
| AudioPreferences | EQ settings, normalization, bass boost, etc. | Yes (history + rollback) |
| LayoutPreferences | Sidebar mode (compact or expanded) and active tab |
Yes (history + rollback) |
| PlayerPreferences | Volume, repeat mode, shuffle, crossfade and ReplayGain | Yes (history + rollback) |
| EqDeviceProfile | Named EQ profiles per device with activate/deactivate | No |
| ThemeMood | UI theme mood: dark, warm, cool or balanced (default dark) |
No |
| UserSettings | Scalar per-user settings declared by setting definitions; currently the email language | No |
Ports
| Port | Purpose |
|---|---|
AccentColorPortInterface |
Get and update the authenticated user's accent color |
SidebarConfigPortInterface |
Get, update, or reset per-media-type sidebar configuration |
AudioPreferencesPortInterface |
Get, save (versioned), get history, and rollback audio preferences |
LayoutPreferencesPortInterface |
Get, save (versioned), get history, and rollback layout preferences |
PlayerPreferencesPortInterface |
Get, save (versioned), get history, and rollback player preferences |
PreferenceWriterPortInterface |
Versioned write shared by the audio, layout and player adapters |
EqDeviceProfilePortInterface |
CRUD + activate EQ device profiles |
ThemeMoodPortInterface |
Get and set theme mood |
UserSettingStoreInterface |
Read, save and delete rows of user_settings |
UserSettingsContractInterface |
Published contract for other contexts: resolve a user's email language, seed it at registration, and read, set and reset a user's settings for administrators |
API Endpoints
All endpoints require authentication and are scoped to the authenticated user.
Accent Color
| Method | Path | Purpose |
|---|---|---|
| GET | /api/user/accent-color |
Get accent color (default: violet) |
| PUT | /api/user/accent-color |
Update accent color |
Sidebar Config
Operates per media type. Valid media types: music, movies, tv, podcasts, concerts, ebooks.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/user/sidebar-config/{mediaType} |
Get sidebar config for a media type (returns defaults if unset) |
| PUT | /api/user/sidebar-config/{mediaType} |
Update sidebar sections for a media type |
| DELETE | /api/user/sidebar-config/{mediaType} |
Reset to defaults (returns 200 with default config, not 204) |
Audio Preferences
| Method | Path | Purpose |
|---|---|---|
| GET | /api/user/audio-preferences |
Get current audio preferences |
| PUT | /api/user/audio-preferences |
Save audio preferences (versioned) |
| GET | /api/user/audio-preferences/history |
Get version history |
| POST | /api/user/audio-preferences/rollback |
Rollback to a specific version |
Layout Preferences
| Method | Path | Purpose |
|---|---|---|
| GET | /api/user/layout-preferences |
Get current layout preferences |
| PUT | /api/user/layout-preferences |
Save layout preferences (versioned) |
| GET | /api/user/layout-preferences/history |
Get version history |
| POST | /api/user/layout-preferences/rollback |
Rollback to a specific version |
Layout preferences require mode (compact or expanded) and activeTab.
Player Preferences
| Method | Path | Purpose |
|---|---|---|
| GET | /api/user/player-preferences |
Get current player preferences |
| PUT | /api/user/player-preferences |
Save player preferences (versioned) |
| GET | /api/user/player-preferences/history |
Get version history |
| POST | /api/user/player-preferences/rollback |
Rollback to a specific version |
Player preferences use a strict 9-field payload. Notable fields: volume is a float 0–1 (not 0–100), repeat is one of off, all, one.
EQ Device Profiles
| Method | Path | Purpose |
|---|---|---|
| GET | /api/user/eq-profiles |
List user's EQ profiles |
| POST | /api/user/eq-profiles |
Create a new EQ profile |
| GET | /api/user/eq-profiles/{id} |
Get a specific profile |
| PUT | /api/user/eq-profiles/{id} |
Update a profile |
| DELETE | /api/user/eq-profiles/{id} |
Delete a profile |
| POST | /api/user/eq-profiles/{id}/activate |
Activate a profile for the user |
Every port method takes the user's ID along with the profile ID. A profile that does not exist, or belongs to another user, raises EqDeviceProfileNotFound, which the controller answers with 404.
Theme Mood
| Method | Path | Purpose |
|---|---|---|
| GET | /api/user/theme-mood |
Get theme mood |
| PUT | /api/user/theme-mood |
Update theme mood |
User Settings
| Method | Path | Purpose |
|---|---|---|
| GET | /api/user/settings |
Every user setting with the user's choice, the effective value, the resetValue, the source (user, server_default or default), whether it is editable, and its definition |
| PUT | /api/user/settings/{key} |
Store a choice; the body is {"value": …} |
| DELETE | /api/user/settings/{key} |
Remove the choice |
An unknown key is 404. A key that names a system setting, or a user setting whose editRole is not ROLE_USER, is 403. An invalid value is 422 with the violation under the key, and nothing is stored. The controller identifies the user through Auth's AuthenticatedUserIdentityInterface; the older controllers cast to SecurityUser.
Administrators reach the same store through Auth's endpoints under /api/admin/users/{id}/settings and app:user:setting, which call the settings contract (see Auth).
Versioned Save Pattern
Audio, Layout, and Player preferences support versioned saves with history and rollback. The PUT body is {"payload": {...}, "version": n}, where version is the version the client last read; a first save sends 0. VersionedPreferencesWriter applies optimistic locking in one transaction: a first save inserts version 1, and a later save updates the row only if its version still equals the expected one, then increments it. Every successful save also appends the payload to preference_history. If the expected version is stale, the writer throws PreferenceVersionConflict, and the controller answers 409 with the currentVersion.
GET .../history → [{version: 3, payload: {...}}, {version: 2, ...}, ...]
POST .../rollback → {version: 2} → saves version 2's payload as a new version
Rollback reads the payload from history and saves it through the same writer, so it produces a new version rather than rewinding the counter.
User Settings
A user setting is a scalar value, typed and validated by a SettingDefinition with SettingScope::User from the Shared setting definition registry. UserPreference contributes its own definitions through LanguageSettingDefinitions; currently the only one is language.
Storage
user_settings has user_id (foreign key to users, ON DELETE CASCADE), key, value jsonb and updated_at timestamptz, with primary key (user_id, key). A check constraint limits value to a JSON boolean, number or string. A row exists only for an explicit choice; resetting deletes it. There is no version and no history: settings are last-write-wins.
UserSettingRepository implements UserSettingStoreInterface with DBAL queries, never the ORM identity map, so a long-running worker sees a change made elsewhere on its next read. UserSettingEntity is a read-only schema mapping only.
Reading
UserSettingsReader turns a definition and the stored value into a UserSettingEntry:
choiceis the stored value while the definition still allows it, otherwisenull. A stored value that is no longer allowed reads as no choice for the user.resetValueis the value the setting has without a choice. For a setting with afallbackKey, it is that system setting's current value; otherwise it is the definition's default.valueischoice ?? resetValue.sourceisuser,server_default(no choice, and the setting follows a system setting) ordefault.
resetValue is how the web app's Email language control names the current server default even after the user has chosen a language. Signed-in users never call the admin settings endpoint.
Writing
SetUserSettingHandler parses the input with SettingValueParser, the same parser the system settings use, and saves the typed value; an invalid value raises InvalidSettingValuesException. ResetUserSettingHandler deletes the row. Neither handler checks who may edit the setting: UserSettingsController refuses settings the user may not change, and the admin path in Auth may change every user setting.
The settings contract
Other contexts reach user settings only through UserSettingsContractInterface and its DTO UserSettingView. The two classes form the UserPreference Settings Contract Deptrac layer; the rest of UserPreference Application stays out of reach. UserSettingsContract in Infrastructure implements it by calling the reader and the two handlers directly, so a call joins the caller's open transaction.
| Method | Caller | Purpose |
|---|---|---|
resolveLanguage() |
Auth AfterResponseMailer, Notification SendEmailHandler |
The email language: the user's choice while it is offered, otherwise the server default while it is offered, otherwise English |
seedLanguage() |
Auth RegisterUserHandler |
Store the browser's language as the user's choice, only when it is offered and differs from the current server default |
settings(), setting() |
Auth AdminUserSettings |
The user's settings as UserSettingView, with a stored value that is no longer allowed kept visible and marked invalid |
set(), reset() |
Auth AdminUserSettings |
Change a user's setting on an administrator's behalf, including settings the user may not change |
Every read goes to the stores afresh, so the language of an email is decided when it is sent.
Moving Preferences onto User Settings
The preference subsystems predate the settings mechanism. Each has its own port, adapter, controller, entity and table, and most have a domain model as well. The plan is to move them onto user_settings where the store fits them, in this order:
- Theme mood and accent color first. Both are one scalar per user with a default, which is exactly what a user setting is. Each becomes a definition in a UserPreference provider with
editRole: ROLE_USER: theme mood an enum ofdark,warm,coolandbalancedwith defaultdark, and accent color an enum of the web palette (VALID_ACCENTSinui/web/src/shared/theme/theme.types.ts) with defaultviolet. Today the API accepts any accent color string of up to 32 characters, and the string type of a setting definition has no length bound, so an enum is the way to keep the value validated. A stored value outside the enum then reads as no choice, as described under Reading. The web app switches to/api/user/settings/{key}, and the old ports, adapters, controllers, entities and theuser_accent_colorsanduser_theme_moodstables are removed. Copy only rows that differ from the default, because a row inuser_settingsmeans an explicit choice. - The versioned payloads only if the store gains versioning. Audio, layout and player preferences are JSON objects with optimistic locking and a history that rollback depends on.
user_settingsholds scalar values only (its check constraint enforces it) and has neither versions nor history. These three move only after the store is given both; until then they keep their tables andVersionedPreferencesWriter. - Sidebar configuration and EQ profiles stay aggregates. A sidebar configuration is an ordered list of items per media type, and EQ profiles are several named records per user with an active one. Neither is one value per key, so neither belongs in a key-value store.
Domain Models
SidebarConfig
Per-media-type sidebar configuration.
| Property | Description |
|---|---|
userId |
The user who owns this configuration |
items |
Ordered collection of SidebarItem entries |
updatedAt |
Last modification timestamp |
SidebarItem
An individual sidebar entry with label, icon, route, order, and visible properties.
Other models
AudioPreferences, LayoutPreferences, PlayerPreferences, EqDeviceProfile and PreferenceHistory use the state-object pattern (*State classes). Accent color and theme mood have no domain model; their repositories store the value directly.
Cross-Context Relationships
| Direction | Context | Details |
|---|---|---|
| Depends on | Shared | Uuid, the setting definition registry, SettingValueParser, and SystemSettingsPortInterface for the system setting a user setting follows |
| Depends on | Auth | The current user: SecurityUser in the preference controllers, AuthenticatedUserIdentityInterface in UserSettingsController |
| Depended on by | Auth | Registration seeds the email language; credential emails resolve it; the admin user settings endpoints and app:user:setting read and change settings, all through UserSettingsContractInterface |
| Depended on by | Notification | SendEmailHandler resolves each recipient's email language through UserSettingsContractInterface |
Infrastructure
| Component | Type | Purpose |
|---|---|---|
UserAccentColorEntity |
Doctrine entity | Accent color storage (user_accent_colors) |
SidebarConfigEntity |
Doctrine entity | Sidebar configuration |
AudioPreferencesEntity |
Doctrine entity | Versioned audio preferences |
LayoutPreferencesEntity |
Doctrine entity | Versioned layout preferences |
PlayerPreferencesEntity |
Doctrine entity | Versioned player preferences |
PreferenceHistoryEntity |
Doctrine entity | History of the versioned preferences (preference_history) |
EqDeviceProfileEntity |
Doctrine entity | EQ device profiles |
UserThemeMoodEntity |
Doctrine entity | Theme mood storage (user_theme_moods) |
UserSettingEntity |
Doctrine entity | Read-only schema mapping of user_settings |
VersionedPreferencesWriter |
DBAL | Optimistic-locking save and history row for the versioned preferences |
UserSettingRepository |
DBAL | Implements UserSettingStoreInterface |
UserSettingsContract |
Contract implementation | Implements UserSettingsContractInterface |
| Doctrine repositories and adapters | ORM | Implement the remaining port interfaces |