Per-user notifications driven by PhoenixKit.Activity.
When an activity is logged with a target_uuid that differs from the
actor_uuid, a row is inserted into phoenix_kit_notifications for the
target user. The user sees it in the bell dropdown (count_unread/1,
recent_for_user/2) and in the inbox at /notifications (list_for_user/2).
Each row carries its own seen_at and dismissed_at — the same activity
can be "seen but not dismissed" for one user and "unseen" for another.
The whole feature is gated by the global notifications_enabled setting
(default "true"); when "false", maybe_create_from_activity/1 is a no-op.
Registered as a core toggleable module (use PhoenixKit.Module) so it
appears on the admin Modules page and contributes the /admin/notifications
overview tab. The module enable/disable flips the same
notifications_enabled kill-switch enabled?/0 reads.
Summary
Functions
Returns {notifications, total_count} across ALL users, newest first, for the
admin overview. Recipient and activity(+actor) are preloaded so the admin
table can show who each notification is for and what it's about.
Aggregate counts for the admin overview page: total notifications,
unread (neither seen nor dismissed), and dismissed. A single
count(...) FILTER (WHERE ...) query — one table scan, not three.
Rescues to zeros so the page never crashes on a query hiccup.
Counts undismissed, unseen notifications for a user. Drives the badge.
Create a standalone notification — one not tied to an activity (V126). Use for app-driven notices that don't originate from the activity log (e.g. "your export is ready").
Insert an in-app-only notification row directly — used by the DigestWorker to
post an aggregated in-app summary ("1,432 likes this hour"). Bypasses the
kill-switch/preference checks (the digest already decided to post) and never
routes externally. display carries :text / :icon / :link.
Dismisses a single notification, also marking it read. Idempotent.
Bulk-dismisses all undismissed notifications, also marking them read. Returns {count, nil}.
Is the notifications feature enabled? Default true.
Fans ONE committed activity entry out to MANY users' notification
rules — the multi-recipient counterpart to maybe_create_from_activity/1
for events whose audience is a set (project members, watchers) rather
than a single target_uuid.
Fetches one notification scoped to the recipient. Returns nil if missing.
Returns {notifications, total_count} for the given user — unseen first,
then newest first within each group.
Bulk-marks all unseen notifications as seen. Returns {count, nil}.
Marks a single notification as seen. Idempotent — already-seen rows return
{:ok, notification} unchanged.
Inserts a notification for the activity's target user, if the rules allow it.
Deletes notifications whose underlying activity is older than days.
Returns the N most-recent undismissed notifications for a user, unseen first.
Restores (un-dismisses) a single notification. Idempotent.
Retention period in days. Falls back to activity retention if unset.
Post an in-app notification, or refresh the one already standing for the
same key.
Functions
Returns {notifications, total_count} across ALL users, newest first, for the
admin overview. Recipient and activity(+actor) are preloaded so the admin
table can show who each notification is for and what it's about.
Deliberately plain newest-first, unlike the per-user reads: "seen" belongs to the recipient, and an admin scanning everybody's notifications is reading a chronological record, not working through their own inbox. Sorting a shared audit feed by whether somebody else has read each row would reorder it differently for no one's benefit.
Options: :page (default 1) / :per_page (default 25).
Aggregate counts for the admin overview page: total notifications,
unread (neither seen nor dismissed), and dismissed. A single
count(...) FILTER (WHERE ...) query — one table scan, not three.
Rescues to zeros so the page never crashes on a query hiccup.
Counts undismissed, unseen notifications for a user. Drives the badge.
Create a standalone notification — one not tied to an activity (V126). Use for app-driven notices that don't originate from the activity log (e.g. "your export is ready").
attrs keys:
:recipient_uuid(required) — who receives it:text/:icon/:link— convenience, folded intometadataasnotification_text/notification_icon/notification_link(the keysRenderreads):metadata— raw metadata map (merged under the convenience keys):type— optional notification type key (e.g."account","posts", or a module-contributed type). When given, the send is filtered through the recipient's per-type preference (Prefs.user_wants_type?/2, fail-open).:action— optional action string (e.g."post.commented"). When given, filtered throughPrefs.user_wants?/2(which maps the action to a type). Use:typeOR:action, not both.Notifications.create(%{ recipient_uuid: user.uuid, text: "Your export is ready.", icon: "hero-arrow-down-tray", link: "/exports/123" })
Honors the global notifications_enabled kill-switch. With neither
:type nor :action, it's an unconditional app-driven send (no
preference filtering). Returns {:ok, %Notification{}},
{:ok, :skipped} (disabled or filtered out by prefs), or
{:error, changeset}. Broadcasts {:notification_created, n} on success.
@spec create_inapp(String.t(), map()) :: {:ok, PhoenixKit.Notifications.Notification.t()} | {:error, term()}
Insert an in-app-only notification row directly — used by the DigestWorker to
post an aggregated in-app summary ("1,432 likes this hour"). Bypasses the
kill-switch/preference checks (the digest already decided to post) and never
routes externally. display carries :text / :icon / :link.
Create a standalone notification for many recipients in one call —
the multi-recipient counterpart to create/1. recipient_uuids is a
list; attrs is the same shape as create/1 minus :recipient_uuid
(it's supplied per recipient).
The recipient list is the caller's responsibility (e.g. the followers
of an author) — this is the generic fan-out primitive, not an audience
resolver. Duplicate uuids are de-duped. Each recipient is filtered
independently through :type / :action prefs when given, so muted
users are skipped. Honors the kill-switch once up front.
Notifications.create_many(follower_uuids, %{
type: "posts",
text: "Alice published a new post.",
link: "/posts/#{post.id}"
})Returns {:ok, created_count} (notifications actually inserted, i.e.
excluding disabled / pref-skipped) or {:ok, :skipped} when
notifications are globally disabled.
Dismisses a single notification, also marking it read. Idempotent.
Bulk-dismisses all undismissed notifications, also marking them read. Returns {count, nil}.
Is the notifications feature enabled? Default true.
@spec fan_out_from_activity(PhoenixKit.Activity.Entry.t(), [String.t()]) :: [ ok: PhoenixKit.Notifications.Notification.t() | :skipped, error: term() ]
Fans ONE committed activity entry out to MANY users' notification
rules — the multi-recipient counterpart to maybe_create_from_activity/1
for events whose audience is a set (project members, watchers) rather
than a single target_uuid.
Each recipient is evaluated independently through the SAME machinery (prefs, per-channel routing, digest cadences, self-action skip) by re-routing a copy of the entry with that recipient as target — WITHOUT inserting additional activity rows, so the feed keeps one canonical entry while deliveries still key on its committed uuid.
Returns the per-recipient results in order.
Fetches one notification scoped to the recipient. Returns nil if missing.
Returns {notifications, total_count} for the given user — unseen first,
then newest first within each group.
Options:
:page(default 1) /:per_page(default 25):status—:unread(seen_at nil) |:all(default)
still honored (:dismissed—:exclude(default, active only):only(the dismissed"trash" view) :include(both). The legacy:include_dismissedbool istrue⇒:include).
Bulk-marks all unseen notifications as seen. Returns {count, nil}.
Marks a single notification as seen. Idempotent — already-seen rows return
{:ok, notification} unchanged.
Inserts a notification for the activity's target user, if the rules allow it.
Returns one of:
{:ok, %Notification{}}— row created; broadcast on the per-user topic{:ok, :skipped}— filtered out (no target, self-action, feature disabled){:error, changeset}— insert failed (logged, never raised)
Deletes notifications whose underlying activity is older than days.
Returns the N most-recent undismissed notifications for a user, unseen first.
Drives the bell dropdown. Activity (and actor) are preloaded.
Unseen first matters more here than in the full list: the dropdown shows a handful, so with a plain newest-first order a notification you had already read could push an unread one off the bottom entirely — the badge counts it, and opening the bell doesn't show it.
Restores (un-dismisses) a single notification. Idempotent.
Retention period in days. Falls back to activity retention if unset.
@spec upsert_inapp(String.t(), String.t(), map()) :: {:ok, PhoenixKit.Notifications.Notification.t()} | {:ok, :skipped} | {:error, term()}
Post an in-app notification, or refresh the one already standing for the
same key.
This is the GitHub-style collapsing entry — "3 new comments on earlier chapters" — where a second event should update the row a user has not dealt with yet rather than add another beside it.
Doing that without an API meant reaching past it: querying
PhoenixKit.Notifications.Notification directly, update_all-ing the row,
and then re-broadcasting :notification_created by hand, because the only
broadcast fired on insert. One host that tried it with a schemaless write
stringified metadata into the jsonb column and 500'd that user's bell.
What counts as "already standing"
An undismissed, unseen notification for the same recipient carrying the same key. Once someone has read it, the next event is news again and gets its own row — collapsing into something already read would hide it, and the unseen-first ordering exists precisely so unread work stays visible.
Refreshing
display replaces the text/icon/link, and inserted_at moves to now so the
refreshed entry sorts as new. Any other metadata keys the caller passes are
merged, leaving keys it doesn't mention alone.
iex> upsert_inapp(user_uuid, "comments:chapter:42", %{text: "3 new comments"})
{:ok, %Notification{}}Broadcasts either way — {:notification_created, n} for a new row,
{:notification_updated, n} for a refresh — so a bell that is already open
reflects it without the caller broadcasting anything itself.
Honors the notifications_enabled kill switch like create/1 does,
returning {:ok, :skipped} when notifications are off — this is a
host-facing entry point, not the digest's private lane, and "off" that
quietly doesn't apply to the newest creation path isn't off.