Zum Hauptinhalt springen
Was this helpful?

Notification Classes Reference

Every notification PrimeCal creates is run through TriageEngineService (backend-nestjs/src/notifications/triage-engine.service.ts) exactly once, synchronously, inside NotificationsService.publish() — before the NotificationMessage row is even saved. This page documents the five priority classes it can produce, the default routing decision for each, the inbox-rule actions that can override that default, and the full notification lifecycle with the entity/column that backs each stage.

This is a deterministic, rules-based engine — there is no ML/LLM scoring involved.

The Five Classes

NotificationClass (backend-nestjs/src/entities/notification-message.entity.ts):

Wire valueWeightDefault routingackRequiredPlain-language meaning
critical100interrupt_nowtrueHard commitments — always interrupts immediately, and floor for classification never goes lower once assigned.
time_critical80interrupt_nowfalseReal time pressure — interrupts as the moment approaches.
coordination60interrupt_nowfalseNeeds a specific responsible person to act.
operational40hold_for_gapfalseDay-to-day updates — held for a natural gap or batched.
fyi10batch_digestfalseGood to know — always batched, never interrupts.

ackRequired is true if and only if the final resolved class is critical (checked once, after all adjustments below).

Routing Decisions

NotificationRoutingDecision (same entity file):

Wire valueMeaning
interrupt_nowDelivered immediately on every enabled channel, subject only to per-channel fallback ordering.
hold_for_gapDelivery to non-in-app channels is delayed ~15 minutes (NotificationDelivery.status = 'scheduled', released by the digest queue's delivery-release job).
batch_digestFolded into the recipient's next hourly/daily digest email instead of sent standalone, if the recipient's own digest preference is immediate (an explicit non-immediate preference is left as-is).

Critical and Time-critical are hard-coded exceptions in NotificationsService.enqueueDeliveries(): they are never held or batched regardless of the recipient's digest/quiet-hours preference or any hold_for_gap/batch_into_digest rule override.

How the Default Class Is Resolved

TriageEngineService.evaluate() runs these steps in order for every recipient of every publish() call:

  1. resolveClass() — checks EVENT_TYPE_CLASS_OVERRIDES (an explicit per-eventType map) first, then falls back to CATEGORY_CLASS_FALLBACK (per notification category), then defaults to fyi if neither matches.

    Explicit event-type overrides (partial — see the source for the full, evolving list):

    Event typeClass
    event.remindertime_critical
    event.canceled / event.deletedcoordination
    reservation.duetime_critical
    reservation.payment.failedcritical
    reservation.created / .updated / .no_showoperational
    system.broadcastcritical
    task.assignment.accepted / .bounced, task.routine.assignedcoordination
    automation.executedfyi
    automation.failedoperational

    Category fallback (used when no explicit override exists for the event type): system → critical, event → time_critical, task → coordination, calendar/reservation/organisation → operational, automation → fyi.

  2. applyHardnessAdjustment() — if the triggering Event.isHardCommitment is true, the class is floored at time_critical (never lower). If it's explicitly false and the resolved class was critical, it's stepped down to time_critical. isHardCommitment is a nullable boolean column added directly on events (see the AddNotificationCenterV2 migration); null/unset means no adjustment either way.

  3. applyFeedbackTuning() — if the recipient has previously sent "too much"/"too quiet" feedback for this class (UserNotificationSettings.feedbackTuning[class], a signed integer), the class steps one tier up or down the ladder fyi → operational → coordination → time_critical → critical. Never applied to critical.

  4. scorePriority() — starting from the class weight above: +15 if a recognized time field in data (startsAt, startAt, startTime, dueDate, eventDate, or scheduledAt) is within 2 hours of now (adjusted by a static, zero-geocoding travelBufferMinutes from a UserNotificationCalendarOverride row, if one exists for that calendar/event type), +5 if within 24 hours, else +0; +10 if the recipient is the responsible party (data.assigneeId/data.ownerId matches the recipient); -15 if they're explicitly not the responsible party on a coordination notification (bystander). Clamped to 0–100.

  5. applyBusyStateHold() — if the recipient is in an active Focus session (checked live via FocusSessionService), operational/fyi notifications (and coordination notifications where the recipient isn't the responsible party) are routed to hold_for_gap instead of their normal default, even though the class itself doesn't change. critical/time_critical, and coordination where the recipient is responsible, still interrupt.

  6. applyFeedbackRouting() — feedback tuning also directly forces the routing decision for this call (too_muchbatch_digest, too_quietinterrupt_now), independent of the class-ladder shift in step 3. Never applied to critical.

Overriding the Default: Inbox-Rule Actions

A NotificationInboxRule (notification-rules.service.ts, NotificationRulesService.applyRuleActions()) can override the engine's output per-recipient via these action types, applied to evaluation.triageOverride and merged on top of the triage engine's result in publish():

Action typePayloadEffect
set_priority{ class: NotificationClass }Forces notificationClass regardless of the engine's default.
route_channels{ channels: NotificationChannelType[] }Forces the exact delivery channel list, overriding the recipient's own channel preferences (not a triage override — sets evaluation.forceChannels directly).
hold_for_gapnoneForces routingDecision: 'hold_for_gap'.
batch_into_digest{ cadence?: 'hourly' | 'daily' }Forces routingDecision: 'batch_digest'; the cadence itself is recorded on metadata.evaluation.batchCadence for audit visibility only — the actual per-channel digest cadence is still the recipient's own UserNotificationPreference.digest setting.
escalate_after{ minutes: number, target: 'group_elevated' | 'org_admin', targetId: number }Stores escalation config on metadata.evaluation.escalation; enforced later by the notifications-escalation-sweep cron (every 5 minutes) — see Lifecycle below. Not yet exposed in the frontend's Advanced Rules builder UI; set it via the API or an MCP-connected agent.

These sit alongside the original rule actions that don't touch triage: suppress_notification, suppress_channels, archive, mark_read, mark_unread, mute_thread — see the Notifications API for the full rule/condition shape.

Mode Packs (NotificationModePackService) are just a convenience layer on top of this same mechanism: selecting focus_adhd or work_store pre-seeds a handful of global rules using exactly these action types (tagged with packOwned so switching packs replaces only that pack's rules, never a user's own).

Lifecycle States

StateBacking entity / columnSet by
CreatedNotificationMessage row inserted; createdAtNotificationsService.publish(), once per non-suppressed recipient
TriagedNotificationMessage.notificationClass, .priorityScore, .routingDecision, .ackRequiredSet at creation time from TriageEngineService.evaluate(), merged with any rule triageOverride — happens in the same publish() call, never as a separate step
ScheduledNotificationDelivery.status = 'scheduled', .metadata.releaseAt / .metadata.digest / .metadata.quietUntilNotificationsService.enqueueDeliveries() when a digest cadence, quiet-hours window, or hold_for_gap routing delays a non-in-app channel; a delayed delivery-release Bull job is queued on the digest queue
DeliveredNotificationDelivery.status = 'sent', .sentAtIn-app deliveries are marked sent immediately; other channels are marked sent by NotificationsDigestProcessor once NotificationChannelRegistry.send() succeeds (or 'failed'/'skipped' on error/NotificationChannelSkipError — also real values of this same column)
SeenNotificationMessage.isRead, .readAtPATCH /:id/read, POST /read-all, or implicitly by POST /:id/act (any inline action marks the message read as a side effect)
ActedNotificationAction row (actionType, actionPayload, actedAt)POST /api/notifications/:id/actNotificationsService.recordAction()
ExpiredNotificationMessage.expiresAt reachedComputed at creation (computeExpiresAt(): ~1h grace after the referenced time for time-bound Critical/Time-critical items, 24h default for other time-bound items, 7 days otherwise); enforced by the notifications-expiry-sweep cron (every 15 min), which currently only sweeps unarchived critical/time_critical messages — it archives the message and fires a notification.recovery follow-up rather than deleting anything
SuppressedTwo distinct mechanisms — see belowRule evaluation in NotificationRulesService.evaluateNotification()

The two forms of "suppressed"

  • Full suppression — an inbox rule's suppress_notification action sets evaluation.suppressed = true. publish() continues for that recipient before any NotificationMessage row is created at all. There is nothing to query afterward for that recipient/event — it simply never existed.
  • Silent archival — a scope mute (NotificationScopeMute) or thread mute (NotificationThreadState.isMuted) matching the notification's calendar/organisation/reservation/thread causes the message to still be created, but immediately archived = true, isRead = true, with metadata.evaluation.silent = true (and scopeMuted/threadMuted flags alongside it). It's excluded from the real-time "new notification" socket push, but it does exist and can still be queried via GET /api/notifications?archived=true.

Not currently wired up

  • NotificationThreadState.lastReadAt exists as a column but no code path in NotificationThreadsService currently sets it — thread-level "seen" state isn't tracked separately from the per-message isRead flag as of this writing.
  • The escalation sweep only escalates messages carrying metadata.evaluation.escalation (from the escalate_after rule action) that are still unread after the configured number of minutes; it fires a notification.escalated notification to the resolved target and stamps NotificationMessage.escalatedAt so it never double-fires.