Skip to content

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:

  1. define the notification rule in notifications.registry
  2. call notify() or notify_event()
  3. provide the payload fields expected by:
  4. templates
  5. navigation
  6. entity inference
  7. 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]

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.changed
  • servicereport.assigned
  • inventory.tool.overdue
  • approvals.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:

  • display
  • category
  • severity
  • pref
  • default
  • channels
  • route
  • default_params
  • entity_type
  • entity_id_key
  • title_template
  • body_template
  • help

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_changes
  • timesheet_reminders
  • inventory_changes

Only split into more granular toggles if there is real UX need.

Why this matters

The preference resolver uses:

  • pref
  • default

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_app
  • push
  • email (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_app creates the inbox record
  • push creates NotificationDelivery rows for async delivery

Step 5: Define navigation

Clients use registry metadata to understand where a notification should lead.

Use these fields:

  • route
  • default_params
  • entity_type
  • entity_id_key

Example

"route": "service_reports.detail",
"default_params": {},
"entity_type": "service_report",
"entity_id_key": "report_id",

This means the payload should include:

{"report_id": "..."}

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": "",
    },
)

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",
    },
)

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": "",
    },
)

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 Notification rows directly
  • create NotificationDelivery rows 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

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=True overrides preference when intended
  • dedupe_key prevents 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

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.",
}

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,
    },
)

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