Notification System
The notification system is a centrally-registered, multi-channel notification framework. A single registry defines all notification types; delivery, channels and visibility are controlled through two settings layers (global/admin and per-user). Templates are multilingual and per channel.
Connection/setup details for the external channels (email mailboxes, Microsoft Teams Bot Framework, Cisco Webex bot, webhooks) live under Integrations. This page describes how notifications are modelled, configured and delivered.
Architecture
NOTIFICATION_REGISTRY (central definition of all types)
175 type keys: entityType, event, category, title, themeColor + NOTIFICATION_CONFIG: defaultChannels, allowedChannels, isCritical,
isEnforced, digestEligible, customerFacing,
accountLifecycle
|
v (seed: one row per type key, admin edits preserved)
notification_type_configs (DB) ← GLOBAL/ADMIN layer |
v
Dispatch
* Resolve effective channels (global ∩ user override) * Account status (not active → accountLifecycle types only) * Quiet-hours filter (except IN_APP / isCritical) * customerFacing check (portal-less contacts) * Render template in the recipient's language * Hand-over to the notification-worker |
v
notification-worker
| | | | |
v v v v v
IN_APP EMAIL PUSH TEAMS WEBEX
(WebSocket) (Worker) (Web Push) (Bot Fwk) (Bot API)
^
channel setup see Integrations
Channels
NotificationChannel = IN_APP | EMAIL | PUSH | WEBEX | TEAMS
| Channel | Transport | Description |
|---|---|---|
IN_APP | WebNotification (DB + WebSocket) | Real-time in portal (bell icon). Always bypasses quiet hours. |
EMAIL | SMTP / MS Graph (email-worker) | Email with signature & layout. Markdown → HTML. |
PUSH | Web Push API (Service Worker) | Browser push, multi-device via VAPID |
TEAMS | Microsoft Bot Framework | Adaptive cards, DM + channel (setup: Integrations) |
WEBEX | Cisco Webex Bot API | Direct messages, native Markdown (setup: Integrations) |
Notification Types & Registry
Each notification type is defined exactly once in the registry. From it, one configuration row (notification_type_configs) is generated per type. The 14 categories:
tickets, problems, changes, incidents,
workflows, users, assets, contracts, licenses,
system, absences, sla, inventory, reports
Per-Type Attributes
| Attribut | Meaning |
|---|---|
defaultChannels | Default channels (user-overridable unless enforced) |
allowedChannels | Channels the user may enable |
isCritical | Bypasses quiet hours (e.g. SLA_BREACH, CHANGE_APPROVAL_REQUIRED) |
isEnforced | User cannot disable (forced delivery) |
digestEligible | Eligible for digest batching (default true) |
customerFacing | Also delivered to portal-less / email-only contacts |
accountLifecycle | Also reaches locked and archived accounts — by email only (see below) |
Example definitions (abbreviated) from the registry:
// defaultChannels: [IN_APP, EMAIL]; allowedChannels: all 5
TICKET_ASSIGNED: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL, customerFacing: true }
TICKET_COMMENT_ADDED: { defaultChannels: [IN_APP], allowedChannels: [IN_APP,EMAIL,PUSH], customerFacing: true }
// Critical → bypasses Quiet Hours
SLA_BREACH: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL, isCritical: true }
CHANGE_APPROVAL_REQUIRED: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL, isCritical: true }
// Email only, customer-facing (e.g. invitation/reset to portal-less users)
USER_INVITATION: { defaultChannels: [EMAIL], allowedChannels: [EMAIL], digestEligible: false, customerFacing: true }
USER_PASSWORD_RESET: { defaultChannels: [EMAIL], allowedChannels: [EMAIL], isCritical: true, customerFacing: true }
// Account lifecycle — reaches an account that is no longer active, email only
USER_ARCHIVED: { defaultChannels: [EMAIL], allowedChannels: [EMAIL], digestEligible: false, accountLifecycle: true }
USER_AUTO_CREATED_PRIVACY_NOTICE: { defaultChannels: [EMAIL], allowedChannels: [EMAIL], customerFacing: true, accountLifecycle: true }
// Reopen / Lifecycle — ticket variants customer-facing (with redaction), Incident/Problem internal
TICKET_REOPENED: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL, customerFacing: true }
TICKET_AUTO_CLOSE_WARNING: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL, customerFacing: true }
TICKET_WC_RESOLVE_WARNING: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL, customerFacing: true }
REOPEN_ESCALATION: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL, isCritical: true } // internal
// {TICKET,PROBLEM,INCIDENT}_STALE_REMINDER + {PROBLEM,INCIDENT}_REOPENED: internal (CH_IA_EM)
// Sub-tickets — internal: to the parent ticket's assignee, never to the customer
TICKET_CHILD_RESOLVED: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL }
TICKET_CHILD_REOPENED: { defaultChannels: CH_IA_EM, allowedChannels: CH_ALL }
customerFacing (customer-facing): Contacts without portal access (no password, emailOnlyContact, autoCreatedFromEmail) receive no email notifications by default. Types with customerFacing=true are delivered to them as well — so e.g. ticket updates or invitations also reach pure email contacts.
Sub-ticket messages on the parent ticket: TICKET_CHILD_RESOLVED (a sub-ticket is done) and TICKET_CHILD_REOPENED (a sub-ticket is open again) go to the parent ticket's assignee; if none is set, to its responsible group. Participants receive them only with the tickets.viewInternal permission. The parent ticket's customer is never a recipient — for them the sub-ticket does not exist. Both messages name the sub-ticket number and the number of sub-tickets still open.
Default channels are the bell and email; all five channels are allowed. Templates are available for IN_APP, EMAIL, WEBEX and TEAMS.
Locked and archived accounts
An account that is not active (locked or archived) receives no notifications — not even customer-facing ones. It can neither sign in nor operate its own settings, so neither user settings nor quiet hours nor the digest apply here. The audit trail records the suppression with the reason account_locked or account_archived; externally both cases look alike, in the audit they stay distinguishable. Open digest buffers of such an account are discarded on the next run — no bundled email goes out.
Account-lifecycle exception (accountLifecycle): A toggle per notification type (Admin → Notifications → Types) lets a type reach a non-active account after all — by email only, without user settings, without quiet hours, without digest. A globally disabled type (isEnabled=false) still sends nothing. Two types carry the toggle: the archival message (USER_ARCHIVED) and the Art. 14 information to auto-created email contacts (USER_AUTO_CREATED_PRIVACY_NOTICE). Types that ask the recipient to sign in (welcome, invitation) deliberately do not carry it — a locked account could not act on that request.
That is why the archival message is an email and not an entry in the bell: an archived account no longer reaches the application and would never have seen the entry.
Settings Layer 1: Global / Admin
Administrators control per type whether it is active, which channels are allowed/default and whether it is enforced. Permission: notifications.editGlobalSettings.
/api/admin/notification-types
| Method | Endpoint | Description |
|---|---|---|
GET | / | All type configs |
GET | /stats | Statistics (enabled/enforced/...) |
GET | /categories | Categories |
GET | /category/:category | Types of a category |
GET | /:typeKey | Single type config |
PATCH | /:typeKey | Update config (channels, flags) |
PUT | /bulk | Multiple types at once |
POST | /:typeKey/enable | Enable type |
POST | /:typeKey/disable | Disable type (global off) |
POST | /:typeKey/enforce | Set enforced (disable user override) |
NotificationTypeConfig
model NotificationTypeConfig {
typeKey String @unique // "TICKET_ASSIGNED", "SLA_BREACH", ...
category String
isEnabled Boolean @default(true)
isEnforced Boolean @default(false) // user cannot override
isCritical Boolean @default(false) // bypasses quiet hours
defaultChannels NotificationChannel[] @default([IN_APP])
allowedChannels NotificationChannel[] @default([IN_APP, EMAIL, PUSH, WEBEX, TEAMS])
digestEligible Boolean @default(true)
customerFacing Boolean @default(false)
accountLifecycle Boolean @default(false) // reaches non-active accounts (EMAIL only)
}
Settings Layer 2: Per User
Each user manages their own preferences: language, timezone, sound/browser push, digest, per-channel quiet hours and per-type channel overrides. Auth: own account.
/api/web-notifications
| Method | Endpoint | Description |
|---|---|---|
GET | / | In-app notifications, paged via ?cursor=&limit= (default 20, max 50). Response: {data, nextCursor, hasMore}. |
GET | /unread | Unread plus total: {data, count}. ?limit= (default 10, max 50). |
PATCH | /:id/read | Mark as read → 204 |
POST | /read-all | Mark all read → 204 |
DELETE | /:id | Delete notification → 204 |
GET | /preferences | Read sound/mute preference: {soundEnabled} |
PATCH | /preferences | Set sound/mute preference: {soundEnabled} |
- The inbox belongs to the caller: every endpoint requires a logged-in user (API keys are rejected) and works only on that user's own rows. A foreign id is therefore indistinguishable from an invented one — both answer 404.
- The three mutations answer 204 with no body and are repeatable: marking or deleting the same row a second time is 204 again. The new unread count arrives over the live connection, not in the response — so the bell counts down in every open window at once.
- The cursor is an opaque value taken from the previous response (nextCursor); a hand-built or truncated cursor is rejected with 400 INVALID_CURSOR.
Where the settings live: The full per-user settings (language, timezone, quiet hours, digest, per-type channel overrides) live under /api/users/:userId/notification-preferences. /api/web-notifications/preferences only serves the sound/mute preference.
UserNotificationSettings
model UserNotificationSettings {
userId String @unique
soundEnabled Boolean @default(true)
browserPushEnabled Boolean @default(false)
preferredLanguage String @default("de")
timezone String @default("Europe/Berlin")
// Digest
digestEnabled Boolean @default(false)
digestFrequency String? // "HOURLY" | "DAILY" | "WEEKLY"
digestTime String? // "08:00"
digestDayOfWeek Int? // 0=Sun … 6=Sat
// Quiet Hours per channel (JSON):
// { "EMAIL": { enabled, startTime:"22:00", endTime:"07:00", days:["MON",...] }, ... }
quietHoursConfig Json @default("{}")
typeSettings UserNotificationTypeSetting[] // per-type overrides
}
model UserNotificationTypeSetting {
typeKey String
isEnabled Boolean @default(true)
// hasChannelOverride=false → use global defaults
// true + channelsOverride=[] → no channels
// true + channelsOverride=[...] → exactly these channels
hasChannelOverride Boolean @default(false)
channelsOverride NotificationChannel[] @default([])
}
Effective Channel Resolution
1. Account not active (locked/archived)? → send nothing; only a type with accountLifecycle goes out — then EMAIL, without user settings, quiet hours and digest2. Type globally disabled (isEnabled=false)? → send nothing3. isEnforced=true? → force defaultChannels (user override ignored)4. else: defaultChannels, filtered by user override (∩ allowedChannels)5. Quiet-Hours-Filter per channel: - IN_APP → always passes - isCritical → always passes - else within quiet hours → suppressed (digest if eligible)6. customerFacing-Guard for portal-less recipients7. Per-channel template render: no active template (isActive=false) or no rendered HTML → channel is skipped (EMAIL falls back to IN_APP), no empty send
In-App Notifications
IN_APP notifications are stored as WebNotification and pushed to the portal in real time via WebSocket (bell icon). They are never suppressed by quiet hours. Endpoints see table above (/api/web-notifications).
The same connection also keeps lists, detail pages and viewer avatars current: Real-time & Presence →
Web Push
Browser push via the Web Push API (VAPID). A user can subscribe on multiple devices; each subscription is bound to the login session (logging out on one device removes only that device's subscription).
/api/push
| Method | Endpoint | Description |
|---|---|---|
GET | /vapid-public-key | Public VAPID key (no auth) |
GET | /status?endpoint= | Status for THIS device: {serverEnabled, masterEnabled, deviceSubscribed, subscriptionCount} |
GET | /subscriptions | List devices: {data} |
POST | /subscribe | Register device (rate-limited): {subscriptionId, deviceName, isNew} |
PATCH | /subscriptions/:id | Change the device label (deviceLabel) → 204 |
DELETE | /subscriptions/current | Unsubscribe current device → 204 |
DELETE | /subscriptions/:id | Unsubscribe specific device → 204 |
DELETE | /subscriptions | Unsubscribe all devices → 204 |
- The public VAPID key is the only endpoint of these two surfaces without authentication — the browser needs it before it can register. All others require a logged-in user and reject API keys with 403.
- A user may register up to 20 devices; registering a 21st is rejected with 409 MAX_PUSH_SUBSCRIPTIONS_REACHED (details carries currentCount and limit). Re-registering the same device does not count towards the limit.
- A registration is bound to the login session; without a session context /subscribe answers 401 NO_SESSION_ID. If no VAPID key pair is configured on the server, /vapid-public-key and /subscribe answer 503 PUSH_NOT_CONFIGURED.
Error codes
| errorCode | HTTP | Meaning |
|---|---|---|
NOTIFICATION_NOT_FOUND | 404 | The row does not belong to the caller or does not exist. An own row that is already read or deleted answers 204 instead. |
INVALID_CURSOR | 400 | The paging cursor is unreadable. Use only the nextCursor from the previous response. |
VALIDATION_ERROR | 400 | A query or body value does not match the schema (limit outside 1–50, unknown field in the preferences, invalid endpoint when registering). |
SUBSCRIPTION_NOT_FOUND | 404 | The device registration does not belong to the caller or does not exist. |
MAX_PUSH_SUBSCRIPTIONS_REACHED | 409 | The ceiling of 20 devices per user is reached. |
NO_SESSION_ID | 401 | Registering and unsubscribing the current device require the session context of the login token. |
PUSH_NOT_CONFIGURED | 503 | No VAPID key pair is configured on the server — push is off server-side. |
Language and date format of a notification
A push message is the only presentation the server has to phrase completely — the device shows it even when the application is closed. It therefore arrives in the recipient's language, drawn from the same source the bell in the browser uses for its text: preferably from the row's stored text building block, otherwise from the type's active IN_APP template, otherwise from the stored text with a translated type heading. A retry after a failed delivery attempt carries the same wording as the first attempt.
Language, timezone and date format are resolved per recipient in this order — the first level that is set wins:
| Property | Chain |
|---|---|
| Language | Notification setting (preferredLanguage) → profile language → system language (general-settings.defaultLanguage) → English |
| Timezone | Notification timezone → profile timezone → general-settings.timezone → Europe/Berlin |
| Date format | Profile setting (dateTimeFormat) → general-settings.dateTimeFormat → dd/MM/yyyy HH:mm |
Every server-generated date therefore follows the same setting as the interface — in email, push, Webex and Teams. A date in a notification looks exactly like the same date in the application. A point in time with a clock time shows its clock time, a deadline on midnight stays a plain date.
Templates (multilingual)
Templates are unique per type + channel and hold the content per language as separate i18n entries — supported are German, English, French, Spanish and Italian. Variables are validated against a schema; preview/render allow testing without sending. Permission: notifications.manageTemplates.
/api/notification-templates/v2
| Method | Endpoint | Description |
|---|---|---|
GET | / | List templates |
GET | /grouped | Grouped by type/channel |
GET | /meta | Metadata (types, channels) |
GET | /statistics | Coverage/statistics |
GET | /variables/:typeKey | Available variables for a type |
GET | /sample-data/:typeKey | Sample data for preview |
GET | /languages | Supported languages |
POST | /test | Send a test notification |
GET | /:id | Single template |
GET | /:id/missing-translations | Missing translations |
POST | / | Create template |
POST | /preview | Preview (without saving) |
POST | /render | Render with variables |
PATCH | /:id | Update template |
PUT | /:id/translations | Create/update a translation |
DELETE | /:id/translations/:languageCode | Delete a translation |
POST | /:id/clone | Clone template |
DELETE | /:id | Delete template |
model NotificationTemplateV2 {
typeKey String // "TICKET_ASSIGNED"
channel NotificationChannel
isActive Boolean @default(true)
editorType String @default("MARKDOWN") // "RICH_TEXT" | "MARKDOWN"
version Int @default(1)
variables String[] // schema for validation
i18n NotificationTemplateI18n[]
@@unique([typeKey, channel])
}
model NotificationTemplateI18n {
languageCode String // 'de' | 'en' | 'fr' | 'es' | 'it'
subject String? // EMAIL only
body String
@@unique([templateId, languageCode])
}
Variables & Rendering
// Template (body):
"**Ticket {{ticketNumber}}** assigned to {{assigneeName}}"
// Render with:
{ ticketNumber: "TK-000123", assigneeName: "John Doe" }
// Result: "**Ticket TK-000123** assigned to John Doe"
// EMAIL channel: Markdown → HTML; auto variables like {{ticketUrl}} added.
Quiet Hours & Digest
Quiet hours suppress channels during defined quiet periods — per channel and in the user's timezone. IN_APP and isCritical types always pass. Emails of digest-eligible types (digestEligible) that fall into quiet hours are rolled into the digest and delivered with the next bundled email.
| quietHoursConfig | Description |
|---|---|
enabled | Quiet hours active for this channel |
startTime / endTime | "22:00" / "07:00" (midnight crossing supported) |
days | MON, TUE, WED, THU, FRI, SAT, SUN |
Digest: In UserNotificationSettings the user picks the email delivery mode Immediate (default) / Hourly / Daily / Weekly — digestEnabled + digestFrequency ("HOURLY"/"DAILY"/"WEEKLY") + digestTime + digestDayOfWeek. A 15-minute cron (notification-digest-dispatch, digest_dispatch action) sends each due user ONE bundled email (NOTIFICATION_DIGEST) in their timezone; after successful delivery the buffered items are deleted. Frame, category headings and rows of the bundled email are all in the recipient's language, and the time on each row follows their date format — in the same timezone the due time is computed in.
Always immediate (never bundled): isCritical and isEnforced types, IN_APP and push, and email-only/portal-less contacts bypass the digest and are delivered immediately. Bulk actions (mass updates) are collapsed into ONE bundled email per recipient, even without an active digest. Administrators control the feature globally via the notification-digest setting (master switch, separate switch for bulk bundling, maximum lines per email) and per type via the digestEligible toggle — GET/PUT /api/admin/notification-types/digest-settings.
Admin Broadcast
Administrators can send a broadcast notification to target groups (user/team/role/department/custom group). Permission: settings.sendBroadcast.
POST /api/admin/notifications/broadcast
{
"broadcastId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Wartungsfenster Samstag 08:00–10:00",
"message": "Das System ist während der Wartung nicht erreichbar.",
"severity": "WARN",
"targetRoleIds": ["clx-role-agent"],
"expiresAt": "2026-08-24T10:00:00Z"
}
- Response: {created, skipped, broadcastId} (201). severity ∈ INFO | WARN | CRITICAL; targetRoleIds empty or omitted = all users.
- broadcastId is the idempotency key: calling again with the same ID creates no second notification and no second push — the response then reports created: 0 and the skipped count.
- expiresAt is optional; without it a broadcast message expires after 30 days and is removed by the retention run.
- Recipients are active accounts only: locked, archived and anonymised accounts are left out — the same rule as for every other delivery.
- A hand-typed title and message is delivered verbatim — it carries no translation building blocks and looks the same for every recipient, whatever their language.
- settings.sendBroadcast is a critical action: the permission is revalidated straight from the database, and every send is recorded as BROADCAST_SENT in the audit trail — as are denied attempts.
Teams & Webex as a Channel
TEAMS delivers adaptive cards (DM or channel) via the Microsoft Bot Framework; WEBEX delivers native Markdown direct messages via the Webex Bot API. Each type's themeColor drives the card color (blue 0078D4, green 107C10, yellow FFB900, red D13438). Connection/bot setup (Azure app, bot token, SSRF allowlist) is documented in Integrations.
Calendar Invites (.ics)
Scheduled change tasks send calendar invites (.ics, METHOD:REQUEST) to assignees, provided the change has scheduledStartTime/scheduledEndTime and is in one of the statuses SCHEDULED, APPROVED or IN_PROGRESS. On reassignment/cancellation a CANCEL is sent. Details see Changes API.
- ✓ One registry, all types derived
- ✓ Global ∩ user → effective channels
- ✓ IN_APP & isCritical bypass quiet hours
- ✓ customerFacing reaches portal-less contacts
- ✓ Non-active accounts receive no delivery
- ✓ Multilingual templates per type + channel
notifications.editGlobalSettings– Global type confignotifications.manageTemplates– Manage templatessettings.sendBroadcast– Send broadcast- Own preferences/push: logged-in user
Auth/role model: Permissions & RBAC
- Integrations – Email, mailboxes, Teams/Webex setup, webhooks, follower/CC
- Changes API – .ics calendar for change tasks
- SLA System – SLA warning/breach notifications
- Reopen & Lifecycle – *_REOPENED, TICKET_AUTO_CLOSE_WARNING, REOPEN_ESCALATION