Skip to content

Notifications — Models

Responsibilities

The notifications app models the full notification lifecycle for users within an organization.

It covers:

  • in-app notification records shown in the UI
  • delivery attempts for external channels such as push
  • registered push devices per user/org
  • scheduled notifications to be fired later
  • Expo receipt tracking for asynchronous push confirmation

The model layer is the persistence backbone of the notifications system.
It does not decide preferences, delivery rules, or retry policy; those belong in services, providers, and tasks.


Main models

Notification

A single notification event for one user in one organization.

This is the canonical inbox record shown in the application UI.

Fields

  • id
  • UUID primary key
  • org
  • foreign key to Organization
  • user
  • foreign key to the recipient user
  • key
  • stable event identifier such as planning.changed
  • actor
  • optional foreign key to the user who caused the event
  • title
  • notification title
  • body
  • notification body text
  • data
  • JSON payload for deep links, entity references, and client context
  • created_at
  • creation timestamp
  • read_at
  • null until marked as read
  • updated_at
  • updated on every save
  • deleted_at
  • soft-delete marker used when clearing notifications
  • group_key
  • optional grouping key for collapsed/grouped UI views
  • dedupe_key
  • optional idempotency key to prevent duplicate notifications

Constraints

  • indexed for:
  • inbox listing by org/user
  • unread filtering
  • key-based browsing
  • grouping
  • deduplication
  • soft-delete filtering
  • updated-at sync flows
  • ordered by -created_at

Relationships

  • belongs to one Organization
  • belongs to one recipient User
  • may reference one actor User
  • has many NotificationDelivery rows

Lifecycle helpers

  • mark_read()
  • sets read_at if unread
  • updates updated_at
  • clear()
  • sets deleted_at if not already cleared
  • updates updated_at

NotificationDelivery

Represents one delivery attempt for a notification on a specific channel.

A single Notification may have multiple delivery rows, for example: - one in-app record - one push delivery - later, one email delivery

Fields

  • id
  • UUID primary key
  • notification
  • foreign key to Notification
  • channel
  • delivery channel, using Notification.Channel
  • status
  • delivery state:
    • pending
    • sent
    • failed
    • skipped
  • provider
  • provider used, for example expo
  • provider_message_id
  • provider-specific ticket/message ID
  • attempts
  • retry attempt counter
  • next_attempt_at
  • when the delivery becomes eligible for retry
  • sent_at
  • timestamp of final send/skip
  • error
  • last error message or summary
  • created_at
  • creation timestamp

Constraints

  • indexed for:
  • worker queue polling by status, channel, next_attempt_at
  • lookup by notification, channel

Relationships

  • belongs to one Notification
  • has many ExpoPushTicket rows when provider is Expo

Notes

  • Notification itself represents the in-app inbox entry
  • NotificationDelivery tracks external delivery state

PushDevice

Represents a device token registered for push notifications.

This is where the system stores which device tokens belong to which user and org.

Fields

  • id
  • UUID primary key
  • user
  • foreign key to the owning user
  • org
  • foreign key to the owning org
  • provider
  • push provider:
    • expo
    • fcm
    • apns
  • token
  • provider push token
  • is_active
  • soft-active flag
  • device_info
  • optional JSON metadata about the device
  • created_at
  • creation timestamp
  • last_seen_at
  • heartbeat timestamp used for stale cleanup

Constraints

  • unique together:
  • provider, token
  • indexed for:
  • user, org, is_active

Relationships

  • belongs to one User
  • belongs to one Organization
  • may have many ExpoPushTicket rows

Notes

  • devices are deactivated rather than deleted when possible
  • last_seen_at supports stale-device cleanup jobs

ScheduledNotification

Represents a future notification creation request.

This model does not track delivery attempts.
Instead, it represents a notification that should be created later by a scheduler task.

Fields

  • id
  • UUID primary key
  • org
  • foreign key to Organization
  • key
  • notification key
  • user
  • target recipient
  • run_at
  • scheduled execution timestamp
  • is_active
  • whether this scheduled row is still pending
  • title
  • optional title override
  • body
  • optional body override
  • data
  • payload JSON
  • dedupe_key
  • optional idempotency key
  • created_at
  • creation timestamp

Constraints

  • indexed for:
  • key
  • run_at
  • dedupe_key

Relationships

  • belongs to one Organization
  • belongs to one User

Notes

  • when due, scheduler code calls notify()
  • after firing, the row is usually deactivated

ExpoPushTicket

Tracks Expo ticket IDs returned during push send.

Expo push is asynchronous: - send step returns ticket IDs - receipt step later confirms final delivery status

This model stores those ticket IDs so receipt checks can be performed later.

Fields

  • id
  • UUID primary key
  • delivery
  • foreign key to NotificationDelivery
  • device
  • optional foreign key to PushDevice
  • ticket_id
  • Expo ticket ID
  • created_at
  • creation timestamp
  • missing_attempts
  • number of times receipt lookup returned nothing
  • next_check_at
  • when to retry receipt lookup
  • checked_at
  • when the receipt was resolved
  • receipt_status
  • final receipt status such as ok, error, or missing
  • receipt_message
  • provider message text
  • receipt_details
  • provider receipt details JSON

Constraints

  • ticket_id is unique
  • indexed for:
  • ticket_id
  • checked_at, created_at
  • delivery

Relationships

  • belongs to one NotificationDelivery
  • optionally belongs to one PushDevice

Lifecycle helpers

  • mark_checked()
  • marks the ticket as terminal
  • stores status, message, and details
  • sets checked_at

Enums and state models

Notification.Channel

Supported notification channels:

  • in_app
  • push
  • email

NotificationDelivery.Status

Delivery states:

  • pending
  • sent
  • failed
  • skipped

PushDevice.Provider

Supported push providers:

  • expo
  • fcm
  • apns

Model relationships

flowchart TD
    O[Organization] --> N[Notification]
    U1[User recipient] --> N
    U2[User actor] --> N

    N --> D[NotificationDelivery]
    U1 --> P[PushDevice]
    O --> P

    O --> S[ScheduledNotification]
    U1 --> S

    D --> T[ExpoPushTicket]
    P --> T

Lifecycle summary

Notification flow

  1. A service creates a Notification
  2. External channels create NotificationDelivery rows
  3. Background tasks process pending deliveries
  4. Provider-specific artifacts such as ExpoPushTicket may be stored
  5. Users mark notifications read or clear them
  6. Cleanup tasks hard-delete cleared notifications after retention

Push device flow

  1. Client registers a device token
  2. A PushDevice row is upserted
  3. Delivery code selects active devices
  4. Invalid devices may be deactivated
  5. Stale devices may later be cleaned up

Scheduled notification flow

  1. A future ScheduledNotification row is created
  2. Scheduler finds rows with run_at <= now
  3. Scheduler calls notify()
  4. The scheduled row is deactivated

Design notes

  • Notification is the canonical inbox record
  • NotificationDelivery tracks external channel delivery
  • soft deletion is preferred over immediate hard deletion
  • grouping and deduplication are built into the model layer
  • Expo receipt tracking is modeled explicitly because final delivery is asynchronous

What these models do not do

These models do not:

  • resolve user preferences
  • decide whether a notification should be sent
  • render templates
  • perform retries or backoff
  • call push providers directly

Those responsibilities belong to:

  • services
  • providers
  • tasks

Best practices

  • use Notification as the source of truth for the user inbox
  • use NotificationDelivery for channel-specific delivery state
  • prefer soft-delete via deleted_at over hard deletes
  • use dedupe_key for idempotent notification creation
  • keep push devices active/inactive instead of deleting aggressively
  • use ExpoPushTicket only for Expo-specific async receipt tracking

Summary

The notifications model layer provides the persistence structure for:

  • inbox notifications
  • delivery attempts
  • push device registrations
  • scheduled sends
  • provider receipt reconciliation

Together, these models support a notification system that is:

  • multi-channel
  • retry-friendly
  • UI-friendly
  • extensible