Notifications — Implementation Guide for Other Apps¶
Purpose¶
This guide explains how other apps should integrate with the notifications system using notifications.registry as the single source of truth.
It is intended for developers working in apps such as:
- timesheets
- planning
- approvals
- inventory
- service reports
- future domain apps
The main goal is consistency.
Every app that emits notifications should:
- define notification types in the registry
- send notifications through notification services
- provide predictable payloads
- avoid embedding notification rules directly in business code
Core principle¶
The registry is the contract.
Do not hardcode notification behavior in random places across the codebase.
Instead:
- define the notification rule in
notifications.registry - call
notify()ornotify_event() - provide the payload fields expected by:
- templates
- navigation
- entity inference
- client UI
What the registry controls¶
A registry rule defines:
- stable event key
- user-facing labels
- category and severity
- preference key
- default enabled state
- delivery channels
- frontend navigation hints
- title/body templates
- optional UX metadata
This means the registry is the shared definition used by:
- services
- templates
- preference resolver
- clients
- settings screens
When another app should use notifications¶
An app should integrate with notifications when it needs to inform users about:
- new assignments
- changed planning
- approvals needed
- status changes
- reminders
- due dates
- ownership changes
- customer actions
- workflow milestones
Typical triggers are business events that matter to a user or role.
Integration flow¶
flowchart TD
A[Business event in app] --> B[Pick registry key]
B --> C[Build payload data]
C --> D[Call notify or notify_event]
D --> E[Registry resolves channels and templates]
E --> F[Preference check]
F --> G[Create Notification]
G --> H[Create NotificationDelivery rows]
H --> I[Tasks process external delivery]
¶
flowchart TD
A[Business event in app] --> B[Pick registry key]
B --> C[Build payload data]
C --> D[Call notify or notify_event]
D --> E[Registry resolves channels and templates]
E --> F[Preference check]
F --> G[Create Notification]
G --> H[Create NotificationDelivery rows]
H --> I[Tasks process external delivery]
Step 1: Add a registry rule¶
Every new notification type starts in notifications.registry.
Choose a stable key:
<domain>.<event><domain>.<subdomain>.<event>when needed
Examples:
planning.changedservicereport.assignedinventory.tool.overdueapprovals.forwarded_to_you
Good key design¶
Good keys are:
- stable
- descriptive
- domain-oriented
- safe to expose to clients
Avoid:
- temporary names
- implementation-specific names
- renaming shipped keys casually
Once a key is used by clients, treat it like an API contract.
Step 2: Define the rule shape¶
A typical rule should include:
displaycategoryseverityprefdefaultchannelsroutedefault_paramsentity_typeentity_id_keytitle_templatebody_templatehelp
Example¶
NOTIFICATION_RULES["inventory.tool.overdue"] = {
"display": "Tool overdue",
"category": "inventory",
"severity": "warning",
"pref": "tool_calibration_due",
"default": True,
"channels": ["in_app", "push"],
"route": "inventory.tools.detail",
"default_params": {},
"entity_type": "tool",
"entity_id_key": "tool_id",
"title_template": "Tool overdue",
"body_template": "{tool_name} is overdue for calibration.",
"help": "Alerts when a tool is overdue.",
}
Step 3: Decide the preference bucket¶
Every rule should map to a user preference key via pref.
Recommendation¶
Keep preferences coarse at first.
Examples:
planning_changestimesheet_remindersinventory_changes
Only split into more granular toggles if there is real UX need.
Why this matters¶
The preference resolver uses:
prefdefault
to determine whether a user should receive the notification.
Step 4: Decide channels¶
The channels field controls which delivery records are created.
Possible values¶
in_apppushemail(later)
Guidance¶
Start with:
["in_app"]
Add push only when:
- the event is important enough
- the expected volume is acceptable
- the UX is clear
Remember¶
in_appcreates the inbox recordpushcreatesNotificationDeliveryrows for async delivery
Step 5: Define navigation¶
Clients use registry metadata to understand where a notification should lead.
Use these fields:
routedefault_paramsentity_typeentity_id_key
Example¶
"route": "service_reports.detail",
"default_params": {},
"entity_type": "service_report",
"entity_id_key": "report_id",
This means the payload should include:
Legacy support¶
deep_link may still exist for backward compatibility, but new integrations should prefer: • route • params • entity metadata
Step 6: Define templates¶
Templates are rendered using payload data.
Use: • title_template • body_template
Example¶
"title_template": "Service report assigned",
"body_template": "You’ve been assigned to service report {report_number}{date_suffix}.",
Important rule¶
If a template expects: • {report_number} • {date_suffix}
then the caller must provide those fields directly or ensure payload normalization can derive them.
Step 7: Build payload data carefully¶
The calling app must provide data that supports: • template rendering • route/entity inference • frontend usage
Example¶
notify(
org=org,
recipients=[user],
key="inventory.tool.overdue",
data={
"tool_id": "123",
"tool_name": "Torque Wrench",
},
)
Good payloads are:¶
• small
• explicit
• stable
• JSON-safe
Avoid passing full model dumps or large blobs.
Step 8: Call notification services¶
Other apps should never create Notification rows directly.
Always use the service layer: • notify() • notify_one() • notify_many() • notify_org() • notify_teams() • notify_org_roles() • notify_teams_roles() • notify_event() • notify_one_event()
Use notify() when:¶
• you already know recipients
• you want direct control over payload/title/body
Use notify_event() when:¶
• you want a cleaner event-style interface
• you want to merge template context and link/navigation data
Common integration patterns¶
Single user event¶
Use when notifying one known user.
notify_one(
org=org,
user=user,
key="servicereport.assigned",
actor=request.user,
data={
"report_id": str(report.id),
"report_number": report.number,
"date_suffix": "",
},
)
¶
notify_one(
org=org,
user=user,
key="servicereport.assigned",
actor=request.user,
data={
"report_id": str(report.id),
"report_number": report.number,
"date_suffix": "",
},
)
Team notification¶
Use when all team members should receive the event.
notify_teams(
org=org,
team_ids=[team.id],
key="planning.changed",
exclude=request.user,
data={
"date_suffix": " for tomorrow",
},
)
¶
notify_teams(
org=org,
team_ids=[team.id],
key="planning.changed",
exclude=request.user,
data={
"date_suffix": " for tomorrow",
},
)
Role-targeted notification¶
Use when only approvers, managers, or other role buckets should receive the event.
notify_org_roles(
org=org,
roles=["admin", "manager"],
key="approval.waiting",
data={
"count": 3,
},
)
Event-style call¶
Useful when combining context and navigation data.
notify_event(
org=org,
recipients=[user],
key="inventory.owner_change.requested",
actor=request.user,
ctx={
"asset_label": asset.name,
"actor_label": request.user.get_full_name(),
},
link={
"asset_id": str(asset.id),
},
)
Choosing recipients¶
Other apps should not duplicate recipient resolution logic badly.
Use the existing targeting helpers indirectly through service wrappers: • org-wide • teams • org roles • team roles
When you already have a concrete queryset or user list, use: • notify() • notify_many()
When recipient logic is common and reusable, prefer service wrappers rather than building ad hoc notification code in views.
Actor usage¶
If the registry rule sets: • actor_required = True
then callers should provide: • actor=...
This is useful for events such as: • assignments • forwarding • ownership changes • delegation creation/removal
Example¶
notify_one(
org=org,
user=recipient,
key="approvals.forwarded_to_you",
actor=request.user,
data={
"approval_id": str(approval.id),
"approval_label": approval.label,
"by_user_label": request.user.get_full_name(),
"note_suffix": "",
},
)
¶
notify_one(
org=org,
user=recipient,
key="approvals.forwarded_to_you",
actor=request.user,
data={
"approval_id": str(approval.id),
"approval_label": approval.label,
"by_user_label": request.user.get_full_name(),
"note_suffix": "",
},
)
Dedupe strategy¶
Use dedupe_key when the same event may be retried.
This is important for: • scheduled jobs • retryable workflows • webhook processing • signals that might run multiple times
Example¶
notify(
org=org,
recipients=[user],
key="timesheet.week_not_submitted",
dedupe_key=f"timesheet-week-missing:{weekly.id}:{user.id}",
data={
"week_suffix": f" for week {weekly.week}",
},
)
This prevents duplicate notifications for the same user.
Grouping strategy¶
Use group_key when the client should collapse multiple notifications together.
Examples: • all notifications for one service report • all notifications for one job • all reminders for one entity
Example¶
notify(
org=org,
recipients=[user],
key="servicereport.status_changed",
group_key=f"service-report:{report.id}",
data={
"report_id": str(report.id),
"report_number": report.number,
"status": report.status,
},
)
This allows collapsed inbox views to show grouped updates.
What other apps should NOT do¶
Do not:
- create
Notificationrows directly - create
NotificationDeliveryrows manually - hardcode channel logic outside the registry
- bypass preferences unless there is a clear reason
- duplicate routing metadata in frontend and backend independently
- embed notification logic in views when it belongs in services
Recommended placement inside other apps¶
Notification triggering usually belongs in:
- service layer
- workflow/orchestration layer
- domain event handling code
Avoid putting notification calls directly in:
- serializers
- models
- views
- templates
Good pattern¶
- app service completes business action
- app service emits notification via notification service
Testing checklist for other apps¶
Whenever a new notification type is added, test at least:
- registry rule exists
- expected channels are created
- templates render correctly
- payload contains required fields
- preference off skips user
force=Trueoverrides preference when intendeddedupe_keyprevents duplicate creation- actor is passed when required
Example test areas¶
- notification creation count
- delivery row creation
- rendered title/body
- skipped users
- grouped key presence
- expected route/entity payload
Recommended implementation checklist¶
Before shipping a new notification type, confirm:
- stable key chosen
- category and display are clear
- preference key is correct
- channels are appropriate
- templates render with provided payload
- route/entity fields make sense
- caller supplies required payload keys
- tests cover happy path and preference-off path
Example full implementation¶
Registry rule¶
NOTIFICATION_RULES["inventory.tool.overdue"] = {
"display": "Tool overdue",
"category": "inventory",
"severity": "warning",
"pref": "tool_calibration_due",
"default": True,
"channels": ["in_app", "push"],
"route": "inventory.tools.detail",
"default_params": {},
"entity_type": "tool",
"entity_id_key": "tool_id",
"title_template": "Tool overdue",
"body_template": "{tool_name} is overdue for calibration.",
"help": "Alerts when a tool is overdue.",
}
¶
NOTIFICATION_RULES["inventory.tool.overdue"] = {
"display": "Tool overdue",
"category": "inventory",
"severity": "warning",
"pref": "tool_calibration_due",
"default": True,
"channels": ["in_app", "push"],
"route": "inventory.tools.detail",
"default_params": {},
"entity_type": "tool",
"entity_id_key": "tool_id",
"title_template": "Tool overdue",
"body_template": "{tool_name} is overdue for calibration.",
"help": "Alerts when a tool is overdue.",
}
service call¶
notify(
org=org,
recipients=[owner],
key="inventory.tool.overdue",
dedupe_key=f"tool-overdue:{tool.id}:{owner.id}",
group_key=f"tool:{tool.id}",
data={
"tool_id": str(tool.id),
"tool_name": tool.name,
},
)
¶
notify(
org=org,
recipients=[owner],
key="inventory.tool.overdue",
dedupe_key=f"tool-overdue:{tool.id}:{owner.id}",
group_key=f"tool:{tool.id}",
data={
"tool_id": str(tool.id),
"tool_name": tool.name,
},
)
Summary¶
When another app needs notifications, the correct implementation pattern is: 1. define the rule in notifications.registry 2. provide a clear payload 3. call notification services 4. let the notifications app handle: • preferences • templates • channels • deduplication • delivery creation
This keeps notifications: • consistent • testable • scalable • frontend-friendly • safe to evolve over time