Skip to main content

UX Standards — 20: Notification System

Governs: The complete notification system — data model, audience targeting, delivery channels, administrative management, user subscriptions, batch job notifications, read/dismiss tracking, retention, and all UI surfaces. Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.


Overview

Anshin Health uses a centralized notification system built on Frappe's SaaS Announcements mechanism. Notifications are first-class data objects — not ephemeral events. Every notification is persisted, scoped to an audience, tracked for read/dismiss state, and subject to retention policies. The system spans in-app UI, email, push, desktop browser notifications, Slack webhooks, and Teams webhooks.


1. Data Model

Core Notification Object

// packages/shared/lib/types/notifications.ts

export type NotificationPriority = 'info' | 'warning' | 'critical';
export type NotificationScope = 'system' | 'company' | 'user';

export interface Notification {
id: string; // Frappe docname
title: string; // Short display title
content: string; // Full notification body (markdown supported)
scope: NotificationScope; // Audience scope (see section 2)
priority: NotificationPriority; // Severity: info | warning | critical
publish_date: string; // ISO 8601 — when notification becomes visible
expiry_date: string | null; // ISO 8601 — when auto-archived (null = no expiry)
action_url: string | null; // Deep link destination (e.g., "/bookings/BK-001")
action_label: string | null; // CTA button label (e.g., "View Booking")
dismissible: boolean; // User can dismiss (hide) this notification
is_read: boolean; // Per-user read state
is_dismissed: boolean; // Per-user dismissed state
source_doctype: string | null; // Origin DocType (e.g., "Booking", "Batch Import")
source_name: string | null; // Origin record name
notification_type: string | null; // Category tag for filtering/subscription
created_by: string; // Frappe user who created/triggered notification
creation: string; // ISO 8601 creation timestamp
}

export interface UnreadCount {
count: number;
has_critical: boolean;
}

export interface NotificationListResponse {
notifications: Notification[];
total: number;
unread_count: number;
has_critical: boolean;
}

Frappe DocType: Anshin Notification

The backing DocType stores notification definitions (not per-user state). Per-user state lives in a child table Anshin Notification User State:

FieldTypeNotes
titleDataRequired
contentLong TextMarkdown
scopeSelectsystem / company / user
target_userLink → UserOnly when scope = user
target_companyLink → CompanyOnly when scope = company
prioritySelectinfo / warning / critical
publish_dateDatetimeDefaults to now
expiry_dateDatetimeNull = never expires
action_urlDataOptional deep link
action_labelDataCTA label
dismissibleCheckDefault: 1 (true)
source_doctypeDataOriginating DocType name
source_nameDynamic LinkOriginating record
notification_typeLink → Notification TypeCategory for subscriptions

Notification User State (child table)

Tracks per-user interaction with each notification:

FieldTypeNotes
userLink → User
is_readCheck
read_atDatetimeNull until read
is_dismissedCheck
dismissed_atDatetimeNull until dismissed

2. Audience Scoping

Three Scope Levels

ScopeWho Sees ItCreated ByUse Cases
systemAll users across all companiesSystem admin onlyPlatform maintenance, critical security alerts, system-wide outages
companyAll users in a specific companyCompany admin or automated processCompany-wide policy changes, company-level batch job results
userSingle specific userAny process targeting that userAssignment notifications, personal batch job completion, direct mentions

Targeting Rules

# Backend: notification creation
if scope == 'system':
# Target: all active users
recipients = frappe.get_all('User', filters={'enabled': 1})

elif scope == 'company':
# Target: all users linked to company
recipients = frappe.get_all('User',
filters={'company': company, 'enabled': 1})

elif scope == 'user':
# Target: single user
recipients = [target_user]

Rule: system-scoped notifications can only be created by users with the System Manager role. Any attempt to create a system-scope notification from a non-admin process raises a PermissionError.


3. Administrative Management

Who Gets Which Notifications

Notification routing is governed by:

  1. Scope — System / Company / User (see section 2)
  2. Notification Type — Category tag that users can subscribe/unsubscribe to
  3. User's NotificationSettings — Per-user channel and frequency preferences
  4. Channel availability — Whether the channel is enabled system-wide

Notification Types (Categories)

Each notification is tagged with a notification_type from the Notification Type DocType. This is what users subscribe to:

TypeDefault OnDescription
system_maintenanceYesScheduled downtime, upgrades
security_alertYesLogin from new device, password changes
batch_job_completeYesImport/export/sync job finished
batch_job_failedYesImport/export/sync job failed
record_assignedYesA record was assigned to me
record_mentionedYesI was @mentioned in a comment
record_sharedYesA document was shared with me
workflow_state_changedNoDocType workflow transitioned
helpdesk_ticket_updateYesMy support ticket was updated
anna_analysis_completeNoAnna AI finished an analysis
document_activityNoActivity on documents I follow
approval_requiredYesA record needs my approval
announcementYesCompany-wide announcements

Notification Lifecycle States

CREATED → PUBLISHED → [READ] → [DISMISSED] → ARCHIVED → DELETED
StateTriggerVisible to User
createdRecord saved with future publish_dateNo
publishedpublish_date reachedYes
readUser clicks notification or "Mark all read"Yes (muted)
dismissedUser clicks X (when dismissible: true)No (filtered out)
archivedexpiry_date reached OR admin batch archiveNo
deletedAdmin hard delete OR retention policy enforcementPermanently gone

Critical distinction: dismissed ≠ deleted. Dismissed notifications remain in the database for audit purposes. Dismissed means "hide from my view" — not permanent deletion. Admins can still query dismissed notifications.


4. Retention Policies

Default Retention Rules

PriorityRead RetentionUnread RetentionSystem Minimum
info30 days90 days7 days
warning60 days120 days30 days
critical180 days365 days90 days
system365 days365 days180 days

Retention is measured from publish_date, not from read date.

Retention Enforcement

A nightly batch job (Anshin Notification Cleanup) runs at 02:00 system time:

# Pseudo-code: nightly cleanup job
def run_notification_cleanup():
now = frappe.utils.now_datetime()

# Archive expired notifications (past expiry_date)
frappe.db.set_value('Anshin Notification',
{'expiry_date': ['<', now], 'status': 'Published'},
'status', 'Archived')

# Delete per retention policy
for priority, days in RETENTION_POLICY.items():
cutoff = now - timedelta(days=days)
frappe.delete_doc('Anshin Notification',
frappe.get_all('Anshin Notification',
filters={
'priority': priority,
'publish_date': ['<', cutoff],
'status': ['in', ['Archived', 'Published']]
}, pluck='name'))

Admin Override

System admins can manually:

  • Archive now — move a notification to archived state immediately
  • Extend retention — set a later expiry_date on any notification
  • Hard delete — permanently delete (requires System Manager role + confirmation dialog)
  • Bulk archive — archive all notifications older than N days for a given company

5. Read Tracking

How Read State Works

ActionEffect
User clicks a notification cardis_read = true, read_at = now
User clicks "Mark all as read"All is_read = false for this user → true
Notification is dismissedDoes NOT automatically mark as read (separate state)
Page navigation to action_url via CTA buttonMarks read

Read State Rendering

// notification-card.tsx
// Unread: solid dot indicator
<span className="h-2 w-2 rounded-full bg-primary flex-shrink-0" />

// Read: ring indicator (muted)
<span className="h-2 w-2 rounded-full ring-1 ring-muted-foreground flex-shrink-0" />

Unread notifications display with a solid bg-primary dot. Read notifications display a muted ring. This pattern is consistent across the Popover, the Sheet (mobile), and the full /notifications page.

Audit Trail

Every read event is persisted in Anshin Notification User State with read_at timestamp. This provides:

  • Compliance reporting — when was a critical alert acknowledged?
  • Helpdesk context — did the user see the warning before submitting the ticket?
  • Analytics — notification open rates by type

6. Dismiss Functionality

Dismissible vs Non-Dismissible

The dismissible boolean is set per notification at creation time:

dismissibleUser can X-buttonDisappears from listRetained in DB
trueYesYes (filtered)Yes (audit)
falseNoNoYes

Non-dismissible notifications are reserved for:

  • Compliance-required notices users MUST acknowledge
  • Critical security alerts that require action
  • System announcements with time-sensitive information

Dismiss vs Delete

  • Dismiss — User hides the notification from their view. The notification remains in the database. The is_dismissed = true flag filters it from API responses to that user. Admins can still see it.
  • Delete — Permanent removal. Only system administrators can delete notifications. This is a prohibited action in the UI for regular users.
// hooks: dismiss notification
export function useDismissNotification() {
return useMutation({
mutationFn: (id: string) =>
frappeClient.call('anshin_orchestrate.notifications.dismiss', { id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notification-count'] });
},
});
}

7. Batch Job Notifications

Overview

Any background process that runs asynchronously MUST emit a notification on completion or failure. This gives users visibility into long-running operations without requiring them to stay on a page or poll manually.

Subscribable Batch Events

Users can opt into notifications for:

Eventnotification_typeDefault Channel
Import job completebatch_job_completeIn-app + Email (instant)
Import job failedbatch_job_failedIn-app + Email (instant)
Export/report readybatch_job_completeIn-app
Sync job completebatch_job_completeIn-app
Workflow batch runbatch_job_completeIn-app
Scheduled reportbatch_job_completeEmail (per frequency)
Data migrationbatch_job_completeIn-app + Email (instant)

Emitting a Batch Notification

Every batch/async job that completes or fails MUST call the notification service:

# Pattern: emit notification from a batch job
from anshin_orchestrate.notifications import emit_notification

def run_import_job(job_id: str, user: str, file_path: str):
try:
result = process_import(file_path)
emit_notification(
title=f"Import Complete: {result.filename}",
content=f"Successfully imported {result.rows} records. "
f"{result.errors} rows had errors.",
scope='user',
target_user=user,
priority='info' if result.errors == 0 else 'warning',
notification_type='batch_job_complete',
action_url=f"/imports/{job_id}",
action_label="View Results",
source_doctype='Batch Import',
source_name=job_id,
dismissible=True,
)
except Exception as e:
emit_notification(
title=f"Import Failed: {job_id}",
content=f"The import job failed: {str(e)}",
scope='user',
target_user=user,
priority='critical',
notification_type='batch_job_failed',
action_url=f"/imports/{job_id}",
action_label="View Error Details",
source_doctype='Batch Import',
source_name=job_id,
dismissible=False, # Force acknowledgment for failures
)
raise

User Job Subscription UI

Users enable batch job notifications in Settings → Notifications → Batch Jobs:

┌─────────────────────────────────────────────────────────────┐
│ Batch & Background Jobs │
│ │
│ Notify me when jobs I start complete: │
│ ● Always ○ Only on failure ○ Never │
│ │
│ Delivery channel for job notifications: │
│ ☑ In-app notification │
│ ☑ Email (immediate) │
│ ☐ Desktop notification │
│ │
│ Job types to notify me about: │
│ ☑ Data imports ☑ Data exports │
│ ☑ Scheduled reports ☐ Workflow batch runs │
│ ☑ System sync jobs ☐ Anna AI analysis │
└─────────────────────────────────────────────────────────────┘

8. User-Configurable Subscriptions

NotificationSettings DocType

Per-user settings stored in Frappe. Every user has one NotificationSettings record (auto-created on first login).

interface NotificationSettings {
// Email
email_notifications: boolean; // Master toggle for all email
email_frequency: 'instant' | 'hourly' | 'daily' | 'weekly';
email_digest: 'none' | 'daily' | 'weekly';
email_on_mention: boolean;
email_on_assignment: boolean;
email_on_share: boolean;

// Push
push_notifications: boolean; // Master toggle for push
push_on_critical: boolean;
push_on_assignment: boolean;
push_on_mention: boolean;

// Desktop
desktop_notifications: boolean; // Master toggle for desktop browser
desktop_on_critical: boolean;

// Document Activity
document_activity: boolean; // Notify on activity for followed docs
document_email_frequency: 'instant' | 'daily' | 'weekly';
}

Subscription by Notification Type

Beyond the master toggles, users can opt in/out per notification_type:

Settings → Notifications → Notification Types

SYSTEM (cannot opt out)
● Security alerts
● System maintenance

WORKFLOW & COLLABORATION
☑ Record assigned to me
☑ @Mentions in comments
☑ Documents shared with me
☑ Approval required
☐ Workflow state changes

BATCH JOBS (see Batch & Background Jobs section)
☑ Import/export complete
☑ Scheduled reports ready
☐ Sync jobs complete

AI & AUTOMATION
☐ Anna AI analysis complete
☐ Workflow automation results

HELPDESK
☑ My ticket was updated
☑ Ticket resolved

ANNOUNCEMENTS
☑ Company announcements

Rule: security_alert and system_maintenance types cannot be unsubscribed. They appear greyed-out with a lock icon and tooltip: "Required — cannot be disabled."


9. Delivery Channels

Channel Matrix

ChannelConfig LocationFrequency OptionsReal-time?
In-app bellAlways onImmediate (polling)Near real-time (15s/60s)
EmailSettings → NotificationsInstant / Hourly / Daily / WeeklyNo (batched by frequency)
Push notificationSettings → NotificationsImmediateYes
Desktop browserSettings → NotificationsImmediateYes
Slack webhookPlatform Settings (admin)Per notificationYes
Teams webhookPlatform Settings (admin)Per notificationYes

Platform-Level Channel Configuration

System administrators configure available channels in Admin → Platform Settings → Notifications:

interface PlatformNotificationConfig {
enable_notifications: boolean; // Master kill switch
notification_channels: string[]; // Enabled channels: ['email','push','desktop','slack','teams']
slack_webhook_url: string | null;
teams_webhook_url: string | null;
max_retention_days: number; // Override default retention
cleanup_schedule: string; // Cron expression for cleanup job
}

Rule: If a channel is disabled at the platform level, the user-level toggle for that channel is hidden (not just disabled). The UI should not promise features the platform hasn't enabled.

Email Delivery

Email notifications are sent via the Frappe email queue. Frequency batching:

FrequencyBehavior
instantEach notification triggers an individual email
hourlyDigest of all notifications from the past hour, sent on the hour
dailyDigest sent at 07:00 user's local time
weeklyDigest sent Monday 07:00 user's local time

Email templates must support multi-language — use the patient/user language stored in their Frappe User record. See 19-INTERNATIONALIZATION-STANDARDS.md for outbound language handling.


10. In-App UI Components

10.1 Notification Bell (Header)

packages/shared/components/layouts/notification-popover.tsx
PropertyValue
Trigger buttonButton variant="ghost" size="icon" h-9 w-9
Bell iconBell h-5 w-5 (Lucide)
Unread badgeAbsolute positioned, -right-0.5 -top-0.5, rounded-full
Badge colorbg-red-500 for critical, bg-primary otherwise
Badge animationanimate-pulse when has_critical: true
Badge capShows 99+ when count > 99

10.2 Notification Popover (Desktop)

PropertyValue
ComponentPopover
Widthw-[380px]
Alignmentalign="end" sideOffset={8}
Scroll areaScrollArea max-h-[400px]
Footer"View all" link → /notifications
Keyboard navArrow keys to navigate notifications
Header actions"Mark all as read" button

10.3 Notification Sheet (Mobile)

PropertyValue
ComponentSheet side="bottom"
Heightmax-h-[80vh]
Border radiusrounded-t-xl
Paddingp-0

Mobile breakpoint: applies when window.innerWidth < 768px (Tailwind md breakpoint).

10.4 Notification Card

packages/shared/components/shared/notification-card.tsx
ElementSpec
Priority indicatorborder-l-4 with priority color
infoborder-blue-500
warningborder-yellow-500
criticalborder-red-500
Read state dotUnread: bg-primary solid; Read: ring-1 ring-muted-foreground
Content collapseline-clamp-2 by default; ChevronDown/Up to expand
Dismiss buttonX icon button — only rendered when notification.dismissible
CTA buttonRendered when action_url is set; uses action_label
Mark readmarkRead.mutate(id) called on card click

10.5 Critical Notification Toast

packages/shared/components/shared/notification-toast.tsx

The NotificationToastMonitor component is an invisible component mounted in the app layout. It monitors the notification stream and fires toasts for NEW critical notifications:

export function NotificationToastMonitor() {
const { data } = useMyNotifications();
const seenIds = useRef<Set<string>>(new Set());
const isFirstLoad = useRef(true);

useEffect(() => {
if (!data?.notifications) return;

if (isFirstLoad.current) {
// Record existing critical IDs without toasting (already seen)
data.notifications
.filter(n => n.priority === 'critical')
.forEach(n => seenIds.current.add(n.id));
isFirstLoad.current = false;
return;
}

// Toast NEW critical notifications only
data.notifications
.filter(n => n.priority === 'critical' && !seenIds.current.has(n.id))
.forEach(n => {
seenIds.current.add(n.id);
toast({
title: n.title,
description: n.content,
variant: 'destructive',
duration: 8000,
action: n.action_url
? <ToastAction altText={n.action_label!} onClick={() => router.push(n.action_url!)}>
{n.action_label}
</ToastAction>
: undefined,
});
});
}, [data]);

return null;
}
PropertyValue
Toast variant'destructive'
Duration8000ms (8 seconds)
Only fires forNEW priority: 'critical' notifications
First load behaviorRecords existing critical IDs without toasting

Rule: NotificationToastMonitor MUST be mounted in every app's dashboard layout. It is placed as a sibling of <main> — not inside <main>.

10.6 Adaptive Polling

// packages/shared/lib/hooks/use-notifications.ts
const POLL_NORMAL = 60_000; // 60 seconds when no critical alerts
const POLL_CRITICAL = 15_000; // 15 seconds when has_critical: true

export function useUnreadCount() {
return useQuery({
queryKey: ['notification-count'],
queryFn: fetchUnreadCount,
refetchInterval: (data) =>
data?.has_critical ? POLL_CRITICAL : POLL_NORMAL,
});
}

The polling interval dynamically adjusts based on notification urgency. When has_critical becomes false, the next poll will revert to 60-second intervals.


11. Full Notifications Page

src/app/(dashboard)/notifications/page.tsx

The full notifications page at /notifications provides comprehensive notification management:

┌─────────────────────────────────────────────────────────────┐
│ Notifications [Mark all read] │
│ │
│ Status: [All] [Unread] [Read] │
│ Priority: [All] [Critical] [Warning] [Info] │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ ● [!] Import Failed: customers.csv 2 min ago │ │
│ │ The import failed: duplicate key on row 145 │ │
│ │ [View Error Details] [X] │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ ○ [i] Booking BK-2024-001 assigned to you 1 hr ago │ │
│ │ Hiroshi Tanaka assigned you to booking... │ │
│ │ [View Booking] [X] │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
ElementSpec
Filter: StatusAll / Unread / Read (Tabs or SegmentedControl)
Filter: PriorityAll / Critical / Warning / Info (Tabs or SegmentedControl)
"Mark all read"Top-right button; useMutattion → useMarkAllRead()
Empty stateIllustration + "You're all caught up" message
Anna contextuseAnnaPageContext({ entityType: 'Notification' })

12. Notification Settings Page

src/app/(dashboard)/settings/notifications/page.tsx

Four sections:

Section 1: Email Notifications

ControlOptions
Master toggleEnable / Disable all email
FrequencyInstant / Hourly / Daily / Weekly
DigestNone / Daily / Weekly
On @mentionToggle
On assignmentToggle
On shareToggle

Section 2: Push Notifications

ControlOptions
Master toggleEnable / Disable push
On critical alertsToggle
On assignmentToggle
On mentionToggle
Browser permissionShows browser permission status; links to browser settings if blocked

Section 3: Desktop Notifications

ControlOptions
Master toggleEnable / Disable desktop
On critical alertsToggle
Permission statusLive permission status badge: Granted / Denied / Default

Requesting desktop notification permission:

const requestPermission = async () => {
const result = await Notification.requestPermission();
// result: 'granted' | 'denied' | 'default'
setPermissionStatus(result);
};

Section 4: Document Activity

ControlOptions
Document activity toggleEnable / Disable
Email frequencyInstant / Daily / Weekly
Note"Get notified about activity on documents you follow"

Platform Configuration Card (System Admin Only)

Shown only when user has System Manager role:

┌─────────────────────────────────────────────────────────────┐
│ Platform Configuration [Admin only] │
│ │
│ Enable notifications system: [●──] │
│ Active channels: │
│ ☑ Email ☑ Push ☑ Desktop ☐ Slack ☐ Teams │
│ │
│ Slack Webhook URL: [https://hooks.slack.com/...] │
│ Teams Webhook URL: [https://outlook.office.com/...] │
└─────────────────────────────────────────────────────────────┘

13. Hooks Reference

// All hooks from packages/shared/lib/hooks/use-notifications.ts

// Get unread count + critical flag (drives bell badge + polling rate)
const { data: count } = useUnreadCount();
// Returns: { count: number, has_critical: boolean }

// Get paginated notification list (drives Popover, Sheet, and full page)
const { data, isLoading } = useMyNotifications({
status?: 'all' | 'unread' | 'read',
priority?: 'all' | 'critical' | 'warning' | 'info',
page?: number,
pageSize?: number,
});

// Mark single notification read
const markRead = useMarkRead();
markRead.mutate(notificationId);

// Dismiss a notification (hide from view, keep in DB)
const dismiss = useDismissNotification();
dismiss.mutate(notificationId);

// Mark all notifications read
const markAllRead = useMarkAllRead();
markAllRead.mutate();

14. Integration Points

HelpDesk Ticket Updates

When a support ticket changes state, the HelpDesk system emits a user-scoped notification:

# Triggered from Frappe HelpDesk webhook
emit_notification(
title=f"Ticket #{ticket.id}: {ticket.subject}",
content=f"Status changed to {ticket.status}",
scope='user',
target_user=ticket.raised_by,
priority='info',
notification_type='helpdesk_ticket_update',
action_url=f"/helpdesk/tickets/{ticket.id}",
action_label="View Ticket",
source_doctype='HD Ticket',
source_name=ticket.name,
)

Anna AI Notifications

When Anna completes a long-running analysis or classification task:

emit_notification(
title="Anna: Analysis Complete",
content=f"Analysis of {document_count} records finished. "
f"Review the results in your dashboard.",
scope='user',
target_user=requesting_user,
priority='info',
notification_type='anna_analysis_complete',
action_url=f"/anna/results/{analysis_id}",
action_label="View Results",
notification_type='anna_analysis_complete',
)

Workflow State Change Notifications

DocType workflow transitions can optionally emit notifications:

# In workflow_after_transition hook
if doc.meta.workflow and settings.notify_on_workflow_transition:
emit_notification(
title=f"{doc.doctype}: {doc.workflow_state}",
content=f"{doc.name} transitioned to {doc.workflow_state}",
scope='user',
target_user=doc.owner,
priority='info',
notification_type='workflow_state_changed',
action_url=f"/{doc.doctype.lower().replace(' ','-')}/{doc.name}",
action_label=f"View {doc.doctype}",
)

15. Security and Permissions

ActionRequired Permission
View own notificationsAny authenticated user
Dismiss own notificationAny authenticated user
Mark own notification readAny authenticated user
Create user-scope notificationAny authenticated user (own API calls)
Create company-scope notificationCompany Manager role
Create system-scope notificationSystem Manager role
View other users' notificationsSystem Manager only
Hard-delete notificationsSystem Manager only
Configure platform settingsSystem Manager only
Access notification analyticsSystem Manager or Company Manager

Security Rule: User A can NEVER read, dismiss, or interact with User B's notifications. All API endpoints filter by frappe.session.user.


16. Violation Checklist

  • Every batch/async job calls emit_notification() on completion AND on failure
  • Batch job failures use dismissible: False to force acknowledgment
  • NotificationToastMonitor mounted in every app's dashboard layout
  • Bell badge uses animate-pulse when has_critical: true
  • Badge shows 99+ cap when count > 99
  • Dismissed ≠ deleted — dismiss sets is_dismissed: true, never deletes the record
  • Non-dismissible notifications have NO X button in the UI
  • Security/maintenance notification types cannot be unsubscribed
  • Read state tracked with read_at timestamp (not just boolean)
  • Adaptive polling: 15s critical, 60s normal
  • Critical notifications toast with variant: 'destructive', duration: 8000
  • First-load toast suppression: existing critical IDs recorded without toasting
  • Email delivery respects user's email_frequency preference
  • Multi-language emails sent in user's preferred language (see doc 19)
  • system-scope notifications gated to System Manager role
  • Anna context wired: useAnnaPageContext({ entityType: 'Notification' }) on notifications page
  • Platform-level channel config card only visible to System Manager role
  • Nightly cleanup job enforces retention policy (not application logic)
  • Desktop notification permission requested via browser API (never assumed granted)
  • Slack/Teams webhooks configured at platform level, NOT per-user