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:
| Field | Type | Notes |
|---|---|---|
title | Data | Required |
content | Long Text | Markdown |
scope | Select | system / company / user |
target_user | Link → User | Only when scope = user |
target_company | Link → Company | Only when scope = company |
priority | Select | info / warning / critical |
publish_date | Datetime | Defaults to now |
expiry_date | Datetime | Null = never expires |
action_url | Data | Optional deep link |
action_label | Data | CTA label |
dismissible | Check | Default: 1 (true) |
source_doctype | Data | Originating DocType name |
source_name | Dynamic Link | Originating record |
notification_type | Link → Notification Type | Category for subscriptions |
Notification User State (child table)
Tracks per-user interaction with each notification:
| Field | Type | Notes |
|---|---|---|
user | Link → User | |
is_read | Check | |
read_at | Datetime | Null until read |
is_dismissed | Check | |
dismissed_at | Datetime | Null until dismissed |
2. Audience Scoping
Three Scope Levels
| Scope | Who Sees It | Created By | Use Cases |
|---|---|---|---|
system | All users across all companies | System admin only | Platform maintenance, critical security alerts, system-wide outages |
company | All users in a specific company | Company admin or automated process | Company-wide policy changes, company-level batch job results |
user | Single specific user | Any process targeting that user | Assignment 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:
- Scope — System / Company / User (see section 2)
- Notification Type — Category tag that users can subscribe/unsubscribe to
- User's
NotificationSettings— Per-user channel and frequency preferences - 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:
| Type | Default On | Description |
|---|---|---|
system_maintenance | Yes | Scheduled downtime, upgrades |
security_alert | Yes | Login from new device, password changes |
batch_job_complete | Yes | Import/export/sync job finished |
batch_job_failed | Yes | Import/export/sync job failed |
record_assigned | Yes | A record was assigned to me |
record_mentioned | Yes | I was @mentioned in a comment |
record_shared | Yes | A document was shared with me |
workflow_state_changed | No | DocType workflow transitioned |
helpdesk_ticket_update | Yes | My support ticket was updated |
anna_analysis_complete | No | Anna AI finished an analysis |
document_activity | No | Activity on documents I follow |
approval_required | Yes | A record needs my approval |
announcement | Yes | Company-wide announcements |
Notification Lifecycle States
CREATED → PUBLISHED → [READ] → [DISMISSED] → ARCHIVED → DELETED
| State | Trigger | Visible to User |
|---|---|---|
created | Record saved with future publish_date | No |
published | publish_date reached | Yes |
read | User clicks notification or "Mark all read" | Yes (muted) |
dismissed | User clicks X (when dismissible: true) | No (filtered out) |
archived | expiry_date reached OR admin batch archive | No |
deleted | Admin hard delete OR retention policy enforcement | Permanently 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
| Priority | Read Retention | Unread Retention | System Minimum |
|---|---|---|---|
info | 30 days | 90 days | 7 days |
warning | 60 days | 120 days | 30 days |
critical | 180 days | 365 days | 90 days |
system | 365 days | 365 days | 180 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_dateon any notification - Hard delete — permanently delete (requires
System Managerrole + confirmation dialog) - Bulk archive — archive all notifications older than N days for a given company
5. Read Tracking
How Read State Works
| Action | Effect |
|---|---|
| User clicks a notification card | is_read = true, read_at = now |
| User clicks "Mark all as read" | All is_read = false for this user → true |
| Notification is dismissed | Does NOT automatically mark as read (separate state) |
Page navigation to action_url via CTA button | Marks 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:
dismissible | User can X-button | Disappears from list | Retained in DB |
|---|---|---|---|
true | Yes | Yes (filtered) | Yes (audit) |
false | No | No | Yes |
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 = trueflag 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:
| Event | notification_type | Default Channel |
|---|---|---|
| Import job complete | batch_job_complete | In-app + Email (instant) |
| Import job failed | batch_job_failed | In-app + Email (instant) |
| Export/report ready | batch_job_complete | In-app |
| Sync job complete | batch_job_complete | In-app |
| Workflow batch run | batch_job_complete | In-app |
| Scheduled report | batch_job_complete | Email (per frequency) |
| Data migration | batch_job_complete | In-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
| Channel | Config Location | Frequency Options | Real-time? |
|---|---|---|---|
| In-app bell | Always on | Immediate (polling) | Near real-time (15s/60s) |
| Settings → Notifications | Instant / Hourly / Daily / Weekly | No (batched by frequency) | |
| Push notification | Settings → Notifications | Immediate | Yes |
| Desktop browser | Settings → Notifications | Immediate | Yes |
| Slack webhook | Platform Settings (admin) | Per notification | Yes |
| Teams webhook | Platform Settings (admin) | Per notification | Yes |
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:
| Frequency | Behavior |
|---|---|
instant | Each notification triggers an individual email |
hourly | Digest of all notifications from the past hour, sent on the hour |
daily | Digest sent at 07:00 user's local time |
weekly | Digest 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
| Property | Value |
|---|---|
| Trigger button | Button variant="ghost" size="icon" h-9 w-9 |
| Bell icon | Bell h-5 w-5 (Lucide) |
| Unread badge | Absolute positioned, -right-0.5 -top-0.5, rounded-full |
| Badge color | bg-red-500 for critical, bg-primary otherwise |
| Badge animation | animate-pulse when has_critical: true |
| Badge cap | Shows 99+ when count > 99 |
10.2 Notification Popover (Desktop)
| Property | Value |
|---|---|
| Component | Popover |
| Width | w-[380px] |
| Alignment | align="end" sideOffset={8} |
| Scroll area | ScrollArea max-h-[400px] |
| Footer | "View all" link → /notifications |
| Keyboard nav | Arrow keys to navigate notifications |
| Header actions | "Mark all as read" button |
10.3 Notification Sheet (Mobile)
| Property | Value |
|---|---|
| Component | Sheet side="bottom" |
| Height | max-h-[80vh] |
| Border radius | rounded-t-xl |
| Padding | p-0 |
Mobile breakpoint: applies when window.innerWidth < 768px (Tailwind md breakpoint).
10.4 Notification Card
packages/shared/components/shared/notification-card.tsx
| Element | Spec |
|---|---|
| Priority indicator | border-l-4 with priority color |
| info | border-blue-500 |
| warning | border-yellow-500 |
| critical | border-red-500 |
| Read state dot | Unread: bg-primary solid; Read: ring-1 ring-muted-foreground |
| Content collapse | line-clamp-2 by default; ChevronDown/Up to expand |
| Dismiss button | X icon button — only rendered when notification.dismissible |
| CTA button | Rendered when action_url is set; uses action_label |
| Mark read | markRead.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;
}
| Property | Value |
|---|---|
| Toast variant | 'destructive' |
| Duration | 8000ms (8 seconds) |
| Only fires for | NEW priority: 'critical' notifications |
| First load behavior | Records 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] │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
| Element | Spec |
|---|---|
| Filter: Status | All / Unread / Read (Tabs or SegmentedControl) |
| Filter: Priority | All / Critical / Warning / Info (Tabs or SegmentedControl) |
| "Mark all read" | Top-right button; useMutattion → useMarkAllRead() |
| Empty state | Illustration + "You're all caught up" message |
| Anna context | useAnnaPageContext({ entityType: 'Notification' }) |
12. Notification Settings Page
src/app/(dashboard)/settings/notifications/page.tsx
Four sections:
Section 1: Email Notifications
| Control | Options |
|---|---|
| Master toggle | Enable / Disable all email |
| Frequency | Instant / Hourly / Daily / Weekly |
| Digest | None / Daily / Weekly |
| On @mention | Toggle |
| On assignment | Toggle |
| On share | Toggle |
Section 2: Push Notifications
| Control | Options |
|---|---|
| Master toggle | Enable / Disable push |
| On critical alerts | Toggle |
| On assignment | Toggle |
| On mention | Toggle |
| Browser permission | Shows browser permission status; links to browser settings if blocked |
Section 3: Desktop Notifications
| Control | Options |
|---|---|
| Master toggle | Enable / Disable desktop |
| On critical alerts | Toggle |
| Permission status | Live 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
| Control | Options |
|---|---|
| Document activity toggle | Enable / Disable |
| Email frequency | Instant / 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
| Action | Required Permission |
|---|---|
| View own notifications | Any authenticated user |
| Dismiss own notification | Any authenticated user |
| Mark own notification read | Any authenticated user |
Create user-scope notification | Any authenticated user (own API calls) |
Create company-scope notification | Company Manager role |
Create system-scope notification | System Manager role |
| View other users' notifications | System Manager only |
| Hard-delete notifications | System Manager only |
| Configure platform settings | System Manager only |
| Access notification analytics | System 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: Falseto force acknowledgment -
NotificationToastMonitormounted in every app's dashboard layout - Bell badge uses
animate-pulsewhenhas_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_attimestamp (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_frequencypreference - Multi-language emails sent in user's preferred language (see doc 19)
-
system-scope notifications gated toSystem Managerrole - Anna context wired:
useAnnaPageContext({ entityType: 'Notification' })on notifications page - Platform-level channel config card only visible to
System Managerrole - 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