Packages

phoenix_kit

1.7.151
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit notifications notifications.ex
Raw

lib/phoenix_kit/notifications/notifications.ex

defmodule PhoenixKit.Notifications do
@moduledoc """
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.
"""
use PhoenixKit.Module
import Ecto.Query, warn: false
require Logger
alias PhoenixKit.Activity.Entry
alias PhoenixKit.Dashboard.Tab
alias PhoenixKit.Notifications.Events
alias PhoenixKit.Notifications.Notification
alias PhoenixKit.Notifications.Prefs
alias PhoenixKit.Settings
# ── Creation ─────────────────────────────────────────────────────────
@doc """
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)
"""
def maybe_create_from_activity(%Entry{} = entry) do
cond do
not enabled?() -> {:ok, :skipped}
is_nil(entry.target_uuid) -> {:ok, :skipped}
entry.target_uuid == entry.actor_uuid -> {:ok, :skipped}
not Prefs.user_wants?(entry.target_uuid, entry.action) -> {:ok, :skipped}
true -> do_create(entry)
end
rescue
e ->
Logger.warning("Notifications.maybe_create_from_activity failed: #{inspect(e)}")
{:ok, :skipped}
end
defp do_create(%Entry{} = entry) do
attrs = %{activity_uuid: entry.uuid, recipient_uuid: entry.target_uuid}
%Notification{}
|> Notification.changeset(attrs)
|> repo().insert()
|> case do
{:ok, notification} ->
# Preload activity so subscribers can render immediately without a roundtrip
notification = %{notification | activity: entry}
Events.broadcast(entry.target_uuid, {:notification_created, notification})
{:ok, notification}
{:error, %Ecto.Changeset{errors: [{_, {_, opts}} | _]} = cs} ->
if Keyword.get(opts, :constraint) == :unique do
# Duplicate insert (retry scenario) — treat as no-op, not an error
{:ok, :skipped}
else
Logger.warning("Notifications insert failed: #{inspect(cs.errors)}")
{:error, cs}
end
end
end
@doc """
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.
"""
def create(attrs) when is_map(attrs) do
cond do
not enabled?() -> {:ok, :skipped}
not wants_standalone?(attrs) -> {:ok, :skipped}
true -> do_create_standalone(attrs)
end
rescue
e ->
Logger.warning("Notifications.create failed: #{inspect(e)}")
{:ok, :skipped}
end
@doc """
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.
"""
def create_many(recipient_uuids, attrs) when is_list(recipient_uuids) and is_map(attrs) do
if enabled?() do
created =
recipient_uuids
|> Enum.uniq()
|> Enum.count(fn uuid ->
match?({:ok, %Notification{}}, create(Map.put(attrs, :recipient_uuid, uuid)))
end)
{:ok, created}
else
{:ok, :skipped}
end
end
# Apply the optional per-recipient preference filter. `:type` checks the
# type pref directly; `:action` maps the action to a type. With neither,
# the send is unconditional.
defp wants_standalone?(%{type: type, recipient_uuid: uuid})
when is_binary(type) and is_binary(uuid),
do: Prefs.user_wants_type?(uuid, type)
defp wants_standalone?(%{action: action, recipient_uuid: uuid})
when is_binary(action) and is_binary(uuid),
do: Prefs.user_wants?(uuid, action)
defp wants_standalone?(_attrs), do: true
defp do_create_standalone(attrs) do
metadata =
(attrs[:metadata] || %{})
|> put_meta("notification_text", attrs[:text])
|> put_meta("notification_icon", attrs[:icon])
|> put_meta("notification_link", attrs[:link])
%Notification{}
|> Notification.changeset(%{
recipient_uuid: attrs[:recipient_uuid],
activity_uuid: nil,
metadata: metadata
})
|> repo().insert()
|> case do
{:ok, notification} ->
# No activity for a standalone row — pin it nil so Render takes the
# metadata path (a freshly-inserted struct otherwise carries a
# NotLoaded association, which Render's activity clause would choke on).
notification = %{notification | activity: nil}
Events.broadcast(notification.recipient_uuid, {:notification_created, notification})
{:ok, notification}
{:error, %Ecto.Changeset{} = cs} ->
Logger.warning("Notifications.create insert failed: #{inspect(cs.errors)}")
{:error, cs}
end
end
defp put_meta(meta, _key, nil), do: meta
defp put_meta(meta, _key, ""), do: meta
defp put_meta(meta, key, val), do: Map.put(meta, key, val)
# ── Reads ────────────────────────────────────────────────────────────
@doc """
Returns `{notifications, total_count}` for the given user, newest first.
Options:
* `:page` (default 1) / `:per_page` (default 25)
* `:status` — `:unread` (seen_at nil) | `:all` (default)
* `:include_dismissed` — include dismissed rows (default `false`)
"""
def list_for_user(user_uuid, opts \\ []) when is_binary(user_uuid) do
page = Keyword.get(opts, :page, 1)
per_page = Keyword.get(opts, :per_page, 25)
status = Keyword.get(opts, :status, :all)
include_dismissed = Keyword.get(opts, :include_dismissed, false)
base_query =
Notification
|> where([n], n.recipient_uuid == ^user_uuid)
|> maybe_filter_dismissed(include_dismissed)
|> maybe_filter_unread(status)
total = repo().aggregate(base_query, :count, :uuid)
rows =
base_query
|> order_by([n], desc: n.inserted_at)
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> repo().all()
|> repo().preload(activity: [:actor])
{rows, total}
end
@doc """
Returns the N most-recent undismissed notifications for a user.
Drives the bell dropdown. Activity (and actor) are preloaded.
"""
def recent_for_user(user_uuid, limit \\ 10) when is_binary(user_uuid) do
Notification
|> where([n], n.recipient_uuid == ^user_uuid and is_nil(n.dismissed_at))
|> order_by([n], desc: n.inserted_at)
|> limit(^limit)
|> repo().all()
|> repo().preload(activity: [:actor])
end
@doc "Counts undismissed, unseen notifications for a user. Drives the badge."
def count_unread(user_uuid) when is_binary(user_uuid) do
Notification
|> where(
[n],
n.recipient_uuid == ^user_uuid and is_nil(n.seen_at) and is_nil(n.dismissed_at)
)
|> repo().aggregate(:count, :uuid)
rescue
_ -> 0
end
@doc "Fetches one notification scoped to the recipient. Returns `nil` if missing."
def get_notification(user_uuid, uuid) when is_binary(user_uuid) and is_binary(uuid) do
Notification
|> where([n], n.uuid == ^uuid and n.recipient_uuid == ^user_uuid)
|> repo().one()
|> maybe_preload()
end
defp maybe_preload(nil), do: nil
defp maybe_preload(%Notification{} = n), do: repo().preload(n, activity: [:actor])
# ── State transitions ────────────────────────────────────────────────
@doc """
Marks a single notification as seen. Idempotent — already-seen rows return
`{:ok, notification}` unchanged.
"""
def mark_seen(user_uuid, uuid) when is_binary(user_uuid) and is_binary(uuid) do
case get_notification(user_uuid, uuid) do
nil ->
{:error, :not_found}
%Notification{seen_at: %DateTime{}} = notification ->
{:ok, notification}
%Notification{} = notification ->
now = DateTime.utc_now() |> DateTime.truncate(:second)
notification
|> Ecto.Changeset.change(seen_at: now)
|> repo().update()
|> broadcast_state(user_uuid, :notification_seen)
end
end
@doc "Bulk-marks all unseen notifications as seen. Returns `{count, nil}`."
def mark_all_seen(user_uuid) when is_binary(user_uuid) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
{count, _} =
Notification
|> where([n], n.recipient_uuid == ^user_uuid and is_nil(n.seen_at))
|> repo().update_all(set: [seen_at: now])
# A bulk-level broadcast lets subscribers refetch; per-row broadcasts would
# be chatty at scale.
Events.broadcast(user_uuid, {:notifications_bulk_updated, :seen})
{count, nil}
end
@doc "Dismisses a single notification. Idempotent."
def dismiss(user_uuid, uuid) when is_binary(user_uuid) and is_binary(uuid) do
case get_notification(user_uuid, uuid) do
nil ->
{:error, :not_found}
%Notification{dismissed_at: %DateTime{}} = notification ->
{:ok, notification}
%Notification{} = notification ->
now = DateTime.utc_now() |> DateTime.truncate(:second)
notification
|> Ecto.Changeset.change(dismissed_at: now)
|> repo().update()
|> broadcast_state(user_uuid, :notification_dismissed)
end
end
@doc "Bulk-dismisses all undismissed notifications. Returns `{count, nil}`."
def dismiss_all(user_uuid) when is_binary(user_uuid) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
{count, _} =
Notification
|> where([n], n.recipient_uuid == ^user_uuid and is_nil(n.dismissed_at))
|> repo().update_all(set: [dismissed_at: now])
Events.broadcast(user_uuid, {:notifications_bulk_updated, :dismissed})
{count, nil}
end
# ── Retention / pruning ─────────────────────────────────────────────
@doc "Deletes notifications whose underlying activity is older than `days`."
def prune(days) when is_integer(days) and days > 0 do
cutoff = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
{count, _} =
from(n in Notification,
join: e in Entry,
on: e.uuid == n.activity_uuid,
where: e.inserted_at < ^cutoff
)
|> repo().delete_all()
Logger.info("Pruned #{count} notifications older than #{days} days")
{:ok, count}
end
@doc "Retention period in days. Falls back to activity retention if unset."
def retention_days do
case Settings.get_setting("notifications_retention_days", nil) do
val when is_binary(val) ->
case Integer.parse(val) do
{n, _} when n > 0 -> n
_ -> fallback_retention()
end
_ ->
fallback_retention()
end
rescue
_ -> fallback_retention()
end
defp fallback_retention do
# Match activity retention when the notifications-specific setting is unset —
# we never want to outlive the activity we reference (it's cascaded anyway).
PhoenixKit.Activity.retention_days()
end
# ── Module behaviour (toggleable module on the admin Modules page) ────
@impl PhoenixKit.Module
def module_key, do: "notifications"
@impl PhoenixKit.Module
def module_name, do: "Notifications"
@impl PhoenixKit.Module
def enable_system, do: Settings.update_boolean_setting("notifications_enabled", true)
@impl PhoenixKit.Module
def disable_system, do: Settings.update_boolean_setting("notifications_enabled", false)
@impl PhoenixKit.Module
def get_config do
# Called for every module on each /admin/modules render. Skip the
# count query entirely when disabled — the stats aren't shown then.
if enabled?() do
Map.merge(%{enabled: true}, admin_stats())
else
%{enabled: false}
end
end
@impl PhoenixKit.Module
def permission_metadata do
%{
key: "notifications",
label: "Notifications",
icon: "hero-bell",
description: "Per-user in-app notifications driven by the activity log"
}
end
@impl PhoenixKit.Module
def admin_tabs do
[
Tab.new!(
id: :admin_notifications,
label: "Notifications",
icon: "hero-bell",
path: "notifications",
priority: 640,
level: :admin,
permission: "notifications",
match: :prefix,
group: :admin_modules,
gettext_backend: PhoenixKitWeb.Gettext
)
]
end
@doc """
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.
"""
def admin_stats do
Notification
|> select([n], %{
total: count(n.uuid),
unread: filter(count(n.uuid), is_nil(n.seen_at) and is_nil(n.dismissed_at)),
dismissed: filter(count(n.uuid), not is_nil(n.dismissed_at))
})
|> repo().one()
rescue
_ -> %{total: 0, unread: 0, dismissed: 0}
end
# ── Settings ─────────────────────────────────────────────────────────
@doc "Is the notifications feature enabled? Default `true`."
@impl PhoenixKit.Module
def enabled? do
# Deliberately the uncached read: this is the kill-switch, and the
# `:settings` cache is node-local with no cross-node invalidation or TTL
# (see PhoenixKit.Cache) — a cached read would let a disable on one node
# go unseen on others until restart. An always-fresh read keeps the
# switch cluster-wide and immediate.
case Settings.get_setting("notifications_enabled", "true") do
"false" -> false
false -> false
_ -> true
end
rescue
_ -> true
end
# ── Internals ────────────────────────────────────────────────────────
defp maybe_filter_dismissed(query, true), do: query
defp maybe_filter_dismissed(query, false), do: where(query, [n], is_nil(n.dismissed_at))
defp maybe_filter_unread(query, :unread), do: where(query, [n], is_nil(n.seen_at))
defp maybe_filter_unread(query, _), do: query
defp broadcast_state({:ok, notification}, user_uuid, event) do
notification = repo().preload(notification, activity: [:actor])
Events.broadcast(user_uuid, {event, notification})
{:ok, notification}
end
defp broadcast_state({:error, _} = err, _user_uuid, _event), do: err
defp repo do
PhoenixKit.RepoHelper.repo()
end
end