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
NotificationDeliveryrows
Lifecycle helpers¶
mark_read()- sets
read_atif unread - updates
updated_at clear()- sets
deleted_atif 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:
pendingsentfailedskipped
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
ExpoPushTicketrows when provider is Expo
Notes¶
Notificationitself represents the in-app inbox entryNotificationDeliverytracks 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:
expofcmapns
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
ExpoPushTicketrows
Notes¶
- devices are deactivated rather than deleted when possible
last_seen_atsupports 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:
keyrun_atdedupe_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, ormissing receipt_message- provider message text
receipt_details- provider receipt details JSON
Constraints¶
ticket_idis unique- indexed for:
ticket_idchecked_at,created_atdelivery
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_apppushemail
NotificationDelivery.Status¶
Delivery states:
pendingsentfailedskipped
PushDevice.Provider¶
Supported push providers:
expofcmapns
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¶
- A service creates a Notification
- External channels create NotificationDelivery rows
- Background tasks process pending deliveries
- Provider-specific artifacts such as ExpoPushTicket may be stored
- Users mark notifications read or clear them
- Cleanup tasks hard-delete cleared notifications after retention
Push device flow¶
- Client registers a device token
- A PushDevice row is upserted
- Delivery code selects active devices
- Invalid devices may be deactivated
- Stale devices may later be cleaned up
Scheduled notification flow¶
- A future ScheduledNotification row is created
- Scheduler finds rows with run_at <= now
- Scheduler calls notify()
- 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