View and update the authenticated user’s notification preferences. Requires a valid JWT access token. Preferences are loaded via GET /api/v1/me/notification-preferences and saved via PUT /api/v1/me/notification-preferences with optimistic auto-save on every change.
- All 7 fields have defined defaults (see entity model below); a preference row is created lazily on the first GET — no migration is run for existing users.
- The full replace strategy is used on PUT (not PATCH) — the client always sends all 7 fields.
- Toggle change triggers an immediate API call (debounced 500 ms to batch rapid multi-toggle changes).
- Optimistic update: FE applies the change locally before the API response; on error it rolls back and shows a toast notification.
- Only
emailBillinghas an active BE gate (HostingDunningService). The remaining 6 fields are persisted for forward-compatibility but have no BE side-effect until the corresponding channels are implemented. - Transactional e-mails (password reset, GDPR export/delete) and admin-initiated e-mails (waitlist invite) are always sent regardless of notification preferences — only
BILLING_ALERTis gated.
sequenceDiagram
actor User
%% --- GET notification preferences ---
User->>+FE: opens Notifications section in Settings
FE->>+BE: GET /api/v1/me/notification-preferences <br> Authorization: Bearer {accessToken}
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
FE-->>User: redirect to login
end
BE->>DB: find UserNotificationPreferences by userId
alt row does not exist (first visit)
BE->>DB: insert row with default values
end
BE->>-FE: 200 OK <br> NotificationPreferencesResponse
FE->>-User: render 1 active toggle (emailBilling) <br> + 6 disabled "Coming soon" controls
%% --- PUT notification preferences ---
User->>+FE: toggles emailBilling switch
FE->>FE: optimistic update (apply change in local state immediately)
FE->>FE: debounce 500 ms
FE->>+BE: PUT /api/v1/me/notification-preferences <br> Authorization: Bearer {accessToken} <br> UpdateNotificationPreferencesRequest
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
FE-->>User: rollback optimistic update <br> show error toast
end
BE->>BE: validate request (digest enum, not_null booleans)
alt request is invalid
BE-->>FE: 400 Bad Request <br> ErrorResponse
FE-->>User: rollback optimistic update <br> show error toast
end
BE->>DB: upsert UserNotificationPreferences for userId <br> set updatedAt = now()
BE->>-FE: 200 OK <br> NotificationPreferencesResponse
FE->>-User: confirmed (no additional UI feedback on success)
Get Notification Preferences
GET /api/v1/me/notification-preferences (no request body)
200 OK NotificationPreferencesResponse:
{
"emailDeploys": true,
"emailErrors": true,
"emailWeekly": true,
"emailBilling": true,
"pushAgent": false,
"pushMentions": true,
"digest": "weekly",
"updatedAt": "2026-05-23T10:00:00Z"
}
401 Unauthorized (missing or invalid access token) ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
Update Notification Preferences
PUT /api/v1/me/notification-preferences UpdateNotificationPreferencesRequest:
{
"emailDeploys": true,
"emailErrors": true,
"emailWeekly": true,
"emailBilling": false,
"pushAgent": false,
"pushMentions": true,
"digest": "weekly"
}
200 OK NotificationPreferencesResponse (returns updated state):
{
"emailDeploys": true,
"emailErrors": true,
"emailWeekly": true,
"emailBilling": false,
"pushAgent": false,
"pushMentions": true,
"digest": "weekly",
"updatedAt": "2026-05-23T10:05:00Z"
}
400 Bad Request (validation) ErrorResponse:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "field": "digest", "message": "must be one of: off, daily, weekly" }
]
}
401 Unauthorized (missing or invalid access token) ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
Entity Model
UserNotificationPreferences
One-to-one with users. Created lazily on first GET.
| Column | Type | Nullable | Default | Note |
|---|---|---|---|---|
id | bigint PK | no | auto | |
user_id | bigint FK | no | FK → users.id, UNIQUE constraint | |
email_deploys | boolean | no | true | FUTURE — channel not implemented |
email_errors | boolean | no | true | FUTURE — channel not implemented |
email_weekly | boolean | no | true | FUTURE — digest not implemented |
email_billing | boolean | no | true | ACTIVE — gates BILLING_ALERT |
push_agent | boolean | no | false | FUTURE — push infra not implemented |
push_mentions | boolean | no | true | FUTURE — push infra not implemented |
digest | varchar | no | 'weekly' | Enum-as-string: off, daily, weekly. FUTURE — scheduler not implemented |
updated_at | timestamptz | no | now() | Set on every upsert |
Liquibase changeset: new file 00XX-add-user-notification-preferences.yaml (exact number = next in sequence). PRODUCTION project — forward-only, never edit existing changesets. BE dev creates the file; schema above is the authoritative spec.
Side-Effect: BILLING_ALERT Gate
HostingDunningService is the only BE consumer of emailBilling. Before sending a BILLING_ALERT e-mail (triggered by HostingDunningBatch on failed payment / PAST_DUE / SUSPENDED events):
- Load
UserNotificationPreferencesfor the account owner byuserId. - If the row does not exist → treat as default (
emailBilling = true) → send the e-mail. - If
emailBilling = false→ write an audit log entry"BILLING_ALERT skipped — user preference emailBilling=false (userId=<id>)"→ do not send e-mail. - If
emailBilling = true→ send the e-mail normally.
E-mail types that are NOT gated (always sent regardless of preferences):
| EmailType | Reason |
|---|---|
FORGOT_PASSWORD | Transactional — security-critical |
WAITLIST_CONFIRMATION | Pre-auth flow |
WAITLIST_INVITE | Admin-initiated action |
GDPR_EXPORT_READY | GDPR Article 15 — mandatory |
GDPR_DELETE_REQUESTED | GDPR Article 17 — mandatory |
GDPR_DELETE_COMPLETED | GDPR Article 17 — mandatory |
UX Guidelines
Page Layout
The Notifications section appears in the Settings sidebar. Content area follows the same padding: 32px 32px 64px layout as other sections.
Toggle Controls
Controls are rendered as ToggleSwitch.vue rows using FieldRow.vue layout: label on the left, control on the right.
| Field | Label (en) | Default | Status |
|---|---|---|---|
emailBilling | Billing alerts (email) | on | Active |
emailDeploys | Deploy notifications (email) | on | Coming soon |
emailErrors | Error alerts (email) | on | Coming soon |
emailWeekly | Weekly digest (email) | on | Coming soon |
pushAgent | Agent activity (push) | off | Coming soon |
pushMentions | Mentions (push) | on | Coming soon |
digest | Digest frequency | weekly | Coming soon |
- Active toggle (
emailBilling): interactiveToggleSwitch, triggers PUT on change. - Coming soon controls: rendered as disabled
ToggleSwitch/ select with a"Coming soon"badge (small pill, muted color). No interaction, no API call. - Digest selector: disabled
<select>with optionsoff | daily | weekly, “Coming soon” badge. Not interactive in this release.
Auto-save Behaviour
- Toggle change → debounced 500 ms before firing
PUT /me/notification-preferences. - Optimistic update on FE before response arrives.
- On API error: roll back to previous value in local state + display toast
"Could not save. Please try again."(var(--rose)background). - No explicit “Save” button.
Loading State
- On initial load, show skeleton placeholders for all rows while GET is in flight.
- Disable all interactive controls during an in-flight PUT request.
Frontend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| emailDeploys | not_null | boolean; field sent even when toggle is disabled in UI | ||
| emailErrors | not_null | boolean | ||
| emailWeekly | not_null | boolean | ||
| emailBilling | not_null | boolean; the only user-interactive toggle in current release | ||
| pushAgent | not_null | boolean | ||
| pushMentions | not_null | boolean | ||
| digest | not_null | ^(off\|daily\|weekly)$ | string enum; selector enforces valid values client-side |
Backend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| emailDeploys | not_null | boolean | ||
| emailErrors | not_null | boolean | ||
| emailWeekly | not_null | boolean | ||
| emailBilling | not_null | boolean | ||
| pushAgent | not_null | boolean | ||
| pushMentions | not_null | boolean | ||
| digest | not_null, enum_value | off, daily, weekly | Reject any value outside these three strings |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| authenticated user with existing preferences row | GET /me/notification-preferences is called | 200 OK, all 8 fields returned with persisted values |
| authenticated user with no existing preferences row (first visit) | GET /me/notification-preferences is called | 200 OK, defaults returned (emailBilling=true, pushAgent=false, digest="weekly", etc.), row created in DB |
| no Authorization header | GET /me/notification-preferences is called | 401 AUTHENTICATION_FAILED error response is returned |
| authenticated user, all fields valid | PUT /me/notification-preferences is called | 200 OK, updated row returned, updatedAt refreshed in DB |
authenticated user sets emailBilling = false | PUT /me/notification-preferences is called | 200 OK, emailBilling=false persisted in DB |
authenticated user with emailBilling = false, HostingDunningBatch fires | BILLING_ALERT send attempted for that user | e-mail NOT sent, audit log entry written: “BILLING_ALERT skipped — user preference emailBilling=false” |
authenticated user with emailBilling = true (default), dunning batch fires | BILLING_ALERT send attempted for that user | e-mail sent normally |
| user has no preferences row, dunning batch fires | BILLING_ALERT send attempted for that user | row absence treated as default (true), e-mail sent normally |
digest = "monthly" (invalid value) | PUT /me/notification-preferences is called | 400 VALIDATION_ERROR, field digest error returned |
emailBilling field missing from request body | PUT /me/notification-preferences is called | 400 VALIDATION_ERROR, field emailBilling error returned |
| no Authorization header | PUT /me/notification-preferences is called | 401 AUTHENTICATION_FAILED error response is returned |
authenticated user saves emailBilling = false, then calls GET again | GET /me/notification-preferences is called | returned emailBilling is false (persisted value reflected) |
Out of Scope
The following remain BACKLOG and are explicitly excluded from this UC:
- Implementation of
emailDeployschannel (no deploy e-mail sender exists). - Implementation of
emailErrorschannel (no error alert e-mail sender exists). - Implementation of
emailWeeklydigest e-mail (no scheduler/digest builder exists). - Push notification infrastructure (
pushAgent,pushMentions) — no push provider integrated. digestscheduler (no cron/digest pipeline exists).- Admin override of another user’s notification preferences.
- Per-project or per-workspace granularity (preferences are global per user).
Thanks for the feedback.