Eviworx
Docs

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.

🔔
Features
✓ Central registry with 175 types (14 categories)
✓ 5 channels (IN_APP, EMAIL, PUSH, WEBEX, TEAMS)
✓ Two settings layers (global + per user)
✓ Reaches portal-less contacts too (customerFacing)
✓ Critical types bypass quiet hours (isCritical)
✓ Enforced types cannot be disabled (isEnforced)
✓ Templates per type + channel in 5 languages
✓ Quiet hours per channel (user timezone)
✓ Optional email digest (HOURLY/DAILY/WEEKLY)
✓ Calendar invites for change tasks (.ics)

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_APPWebNotification (DB + WebSocket)Real-time in portal (bell icon). Always bypasses quiet hours.
EMAILSMTP / MS Graph (email-worker)Email with signature & layout. Markdown → HTML.
PUSHWeb Push API (Service Worker)Browser push, multi-device via VAPID
TEAMSMicrosoft Bot FrameworkAdaptive cards, DM + channel (setup: Integrations)
WEBEXCisco Webex Bot APIDirect 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
defaultChannelsDefault channels (user-overridable unless enforced)
allowedChannelsChannels the user may enable
isCriticalBypasses quiet hours (e.g. SLA_BREACH, CHANGE_APPROVAL_REQUIRED)
isEnforcedUser cannot disable (forced delivery)
digestEligibleEligible for digest batching (default true)
customerFacingAlso delivered to portal-less / email-only contacts
accountLifecycleAlso 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
MethodEndpointDescription
GET/All type configs
GET/statsStatistics (enabled/enforced/...)
GET/categoriesCategories
GET/category/:categoryTypes of a category
GET/:typeKeySingle type config
PATCH/:typeKeyUpdate config (channels, flags)
PUT/bulkMultiple types at once
POST/:typeKey/enableEnable type
POST/:typeKey/disableDisable type (global off)
POST/:typeKey/enforceSet 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
MethodEndpointDescription
GET/In-app notifications, paged via ?cursor=&limit= (default 20, max 50). Response: {data, nextCursor, hasMore}.
GET/unreadUnread plus total: {data, count}. ?limit= (default 10, max 50).
PATCH/:id/readMark as read → 204
POST/read-allMark all read → 204
DELETE/:idDelete notification → 204
GET/preferencesRead sound/mute preference: {soundEnabled}
PATCH/preferencesSet 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
MethodEndpointDescription
GET/vapid-public-keyPublic VAPID key (no auth)
GET/status?endpoint=Status for THIS device: {serverEnabled, masterEnabled, deviceSubscribed, subscriptionCount}
GET/subscriptionsList devices: {data}
POST/subscribeRegister device (rate-limited): {subscriptionId, deviceName, isNew}
PATCH/subscriptions/:idChange the device label (deviceLabel) → 204
DELETE/subscriptions/currentUnsubscribe current device → 204
DELETE/subscriptions/:idUnsubscribe specific device → 204
DELETE/subscriptionsUnsubscribe 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

errorCodeHTTPMeaning
NOTIFICATION_NOT_FOUND404The row does not belong to the caller or does not exist. An own row that is already read or deleted answers 204 instead.
INVALID_CURSOR400The paging cursor is unreadable. Use only the nextCursor from the previous response.
VALIDATION_ERROR400A query or body value does not match the schema (limit outside 1–50, unknown field in the preferences, invalid endpoint when registering).
SUBSCRIPTION_NOT_FOUND404The device registration does not belong to the caller or does not exist.
MAX_PUSH_SUBSCRIPTIONS_REACHED409The ceiling of 20 devices per user is reached.
NO_SESSION_ID401Registering and unsubscribing the current device require the session context of the login token.
PUSH_NOT_CONFIGURED503No 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:

PropertyChain
LanguageNotification setting (preferredLanguage) → profile language → system language (general-settings.defaultLanguage) → English
TimezoneNotification timezone → profile timezone → general-settings.timezone → Europe/Berlin
Date formatProfile 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
MethodEndpointDescription
GET/List templates
GET/groupedGrouped by type/channel
GET/metaMetadata (types, channels)
GET/statisticsCoverage/statistics
GET/variables/:typeKeyAvailable variables for a type
GET/sample-data/:typeKeySample data for preview
GET/languagesSupported languages
POST/testSend a test notification
GET/:idSingle template
GET/:id/missing-translationsMissing translations
POST/Create template
POST/previewPreview (without saving)
POST/renderRender with variables
PATCH/:idUpdate template
PUT/:id/translationsCreate/update a translation
DELETE/:id/translations/:languageCodeDelete a translation
POST/:id/cloneClone template
DELETE/:idDelete 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.

quietHoursConfigDescription
enabledQuiet hours active for this channel
startTime / endTime"22:00" / "07:00" (midnight crossing supported)
daysMON, 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.

🔔
Core Principles
  • ✓ 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
🔐
Permissions (RBAC)
  • notifications.editGlobalSettings – Global type config
  • notifications.manageTemplates – Manage templates
  • settings.sendBroadcast – Send broadcast
  • Own preferences/push: logged-in user

Auth/role model: Permissions & RBAC

Related Documentation