PhoenixKit.Notifications (phoenix_kit v2.6.0)

Copy Markdown View Source

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.

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).

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

admin_list(opts \\ [])

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).

admin_stats()

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.

count_unread(user_uuid)

Counts undismissed, unseen notifications for a user. Drives the badge.

create(attrs)

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 into metadata as notification_text / notification_icon / notification_link (the keys Render reads)

  • :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 through Prefs.user_wants?/2 (which maps the action to a type). Use :type OR :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.

create_inapp(recipient_uuid, display)

@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_many(recipient_uuids, attrs)

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.

dismiss(user_uuid, uuid)

Dismisses a single notification, also marking it read. Idempotent.

dismiss_all(user_uuid)

Bulk-dismisses all undismissed notifications, also marking them read. Returns {count, nil}.

enabled?()

Is the notifications feature enabled? Default true.

fan_out_from_activity(entry, user_uuids)

@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.

get_notification(user_uuid, uuid)

Fetches one notification scoped to the recipient. Returns nil if missing.

list_for_user(user_uuid, opts \\ [])

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)

  • :dismissed:exclude (default, active only):only (the dismissed
    "trash" view):include (both). The legacy :include_dismissed bool is
    still honored (true:include).

mark_all_seen(user_uuid)

Bulk-marks all unseen notifications as seen. Returns {count, nil}.

mark_seen(user_uuid, uuid)

Marks a single notification as seen. Idempotent — already-seen rows return {:ok, notification} unchanged.

maybe_create_from_activity(entry)

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)

prune(days)

Deletes notifications whose underlying activity is older than days.

recent_for_user(user_uuid, limit \\ 10)

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.

restore(user_uuid, uuid)

Restores (un-dismisses) a single notification. Idempotent.

retention_days()

Retention period in days. Falls back to activity retention if unset.

upsert_inapp(recipient_uuid, key, display)

@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.