Packages

phoenix_kit

1.7.221
1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 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.ChannelConfig
alias PhoenixKit.Notifications.DeliveryWorker
alias PhoenixKit.Notifications.Events
alias PhoenixKit.Notifications.Notification
alias PhoenixKit.Notifications.Prefs
alias PhoenixKit.Notifications.Routing
alias PhoenixKit.Notifications.Types
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
# ── 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}
true -> route_activity(entry)
end
rescue
e ->
Logger.warning("Notifications.maybe_create_from_activity failed: #{inspect(e)}")
{:ok, :skipped}
end
# Model B — the in-app inbox and external channels are INDEPENDENT
# destinations. Create the inbox row iff the user wants it in-app AND in-app is
# on the "immediate" cadence (a digest cadence skips the per-event row — the
# DigestWorker posts one summary row later); enqueue external delivery for
# every channel that wants this type. Loading the user once (the pref check +
# channel routing both need it) keeps this at a single extra read.
defp route_activity(%Entry{} = entry) do
user = Auth.get_user(entry.target_uuid)
{in_app?, targets} =
if user do
{Prefs.user_wants?(user, entry.action) and inapp_immediate?(user, entry.action),
Routing.targets_for_action(user, entry.action)}
else
{Prefs.user_wants?(entry.target_uuid, entry.action), []}
end
if in_app? or targets != [] do
do_create(entry, in_app?, targets)
else
{:ok, :skipped}
end
end
# Whether in-app delivery for this action's type is immediate (the default) vs
# a digest cadence. Cadence lives in the synthetic `"inapp"` channel config;
# absent ⇒ "immediate" ⇒ the inbox behaves exactly as before.
defp inapp_immediate?(user, action) do
case Types.key_for_action(action) do
nil ->
true
type_key ->
ChannelConfig.cadence(ChannelConfig.for_channel(user, "inapp"), type_key) == "immediate"
end
end
# The inbox row (if wanted) and external delivery are INDEPENDENT: insert the
# row first and on its own, then enqueue delivery best-effort. The delivery
# jobs key on the already-committed activity uuid (no inbox row required), and
# a failing/absent Oban must never roll back or drop the user-visible inbox
# row — so enqueue is deliberately NOT in the insert's transaction.
defp do_create(%Entry{} = entry, in_app?, targets) do
result = if in_app?, do: insert_inbox_row(entry), else: {:ok, :dispatched}
enqueue_immediate_delivery(entry, targets)
result
end
defp insert_inbox_row(%Entry{} = entry) do
%Notification{}
|> Notification.changeset(%{activity_uuid: entry.uuid, recipient_uuid: entry.target_uuid})
|> repo().insert()
|> case do
{:ok, notification} ->
# Preload activity so subscribers render immediately, no roundtrip.
notification = %{notification | activity: entry}
Events.broadcast(entry.target_uuid, {:notification_created, notification})
{:ok, notification}
{:error, cs} ->
handle_insert_error(cs)
end
end
# One idempotent Oban job per {source, channel} for IMMEDIATE-cadence channels
# (digest cadences are swept by `DigestWorker` on a cron). Best-effort: a
# delivery-enqueue failure — including Oban not being started — is logged and
# swallowed so it can never take down the inbox row or crash activity logging.
defp enqueue_immediate_delivery(%Entry{} = entry, targets) do
type_key = Types.key_for_action(entry.action)
base = %{
"activity_uuid" => entry.uuid,
"recipient_uuid" => entry.target_uuid,
"type_key" => type_key
}
if is_binary(type_key) do
for {channel, config} <- targets, ChannelConfig.immediate?(config, type_key) do
Oban.insert(DeliveryWorker.build(Map.put(base, "channel", channel.key())))
end
end
:ok
rescue
e ->
Logger.warning("Notifications delivery enqueue failed: #{inspect(e)}")
:ok
end
# A duplicate (activity_uuid, recipient_uuid) insert is a no-op, not an error.
defp handle_insert_error(%Ecto.Changeset{errors: [{_, {_, opts}} | _]} = cs) do
if Keyword.get(opts, :constraint) == :unique do
{:ok, :skipped}
else
Logger.warning("Notifications insert failed: #{inspect(cs.errors)}")
{:error, cs}
end
end
defp handle_insert_error(cs) do
Logger.warning("Notifications insert failed: #{inspect(cs.errors)}")
{:error, cs}
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
@doc """
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`.
"""
@spec create_inapp(String.t(), map()) :: {:ok, Notification.t()} | {:error, term()}
def create_inapp(recipient_uuid, display) when is_binary(recipient_uuid) and is_map(display) do
metadata =
%{}
|> put_meta("notification_text", display[:text])
|> put_meta("notification_icon", display[:icon])
|> put_meta("notification_link", display[:link])
%Notification{}
|> Notification.changeset(%{
recipient_uuid: recipient_uuid,
activity_uuid: nil,
metadata: metadata
})
|> repo().insert()
|> case do
{:ok, notification} ->
notification = %{notification | activity: nil}
Events.broadcast(recipient_uuid, {:notification_created, notification})
{:ok, notification}
{:error, %Ecto.Changeset{} = cs} ->
Logger.warning("Notifications.create_inapp failed: #{inspect(cs.errors)}")
{:error, cs}
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
# NOTE: standalone external delivery requires the inbox row (`create/1`'s
# `wants_standalone?` gate has already passed here) — i.e. standalone
# notifications are NOT independently routable the way activity-driven ones
# are. Standalone messages are app-authored one-offs; the per-type
# "inbox-off, Telegram-on" independence is an activity-driven concern.
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])
recipient = attrs[:recipient_uuid]
user = recipient && Auth.get_user(recipient)
targets = standalone_targets(user, attrs)
type_key = standalone_type_key(attrs)
changeset =
Notification.changeset(%Notification{}, %{
recipient_uuid: recipient,
activity_uuid: nil,
metadata: metadata
})
# Decoupled like the activity path: the inbox row must never be lost because
# an external-delivery enqueue hiccuped, so we insert it in its own step and
# enqueue delivery best-effort afterwards (referencing the now-known uuid).
case repo().insert(changeset) 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})
enqueue_standalone_delivery(targets, notification.uuid, recipient, type_key)
{:ok, notification}
{:error, %Ecto.Changeset{} = cs} ->
Logger.warning("Notifications.create insert failed: #{inspect(cs.errors)}")
{:error, cs}
end
end
# External channels wanting this standalone notification. Fail-closed: without
# an explicit `:type` or `:action`, nothing routes externally.
defp standalone_targets(nil, _attrs), do: []
defp standalone_targets(user, %{type: type}) when is_binary(type),
do: Routing.targets_for_type(user, type)
defp standalone_targets(user, %{action: action}) when is_binary(action),
do: Routing.targets_for_action(user, action)
defp standalone_targets(_user, _attrs), do: []
defp standalone_type_key(%{type: type}) when is_binary(type), do: type
defp standalone_type_key(%{action: action}) when is_binary(action),
do: Types.key_for_action(action)
defp standalone_type_key(_attrs), do: nil
# Delivery jobs for a standalone row — keyed on the inserted notification uuid,
# since there's no activity to render from. Best-effort: a failed enqueue must
# not cost the user the inbox row that already persisted.
defp enqueue_standalone_delivery([], _uuid, _recipient, _type_key), do: :ok
defp enqueue_standalone_delivery(targets, notification_uuid, recipient, type_key) do
for {channel, _config} <- targets do
Oban.insert(
DeliveryWorker.build(%{
"notification_uuid" => notification_uuid,
"recipient_uuid" => recipient,
"type_key" => type_key,
"channel" => channel.key()
})
)
end
:ok
rescue
e ->
Logger.warning("Notifications standalone delivery enqueue failed: #{inspect(e)}")
:ok
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)
* `:dismissed` — `:exclude` (default, active only) | `:only` (the dismissed
"trash" view) | `:include` (both). The legacy `:include_dismissed` bool is
still honored (`true` ⇒ `:include`).
"""
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)
base_query =
Notification
|> where([n], n.recipient_uuid == ^user_uuid)
|> filter_dismissed(dismissed_filter(opts))
|> 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 `{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.
Options: `:page` (default 1) / `:per_page` (default 25).
"""
def admin_list(opts \\ []) do
page = Keyword.get(opts, :page, 1)
per_page = Keyword.get(opts, :per_page, 25)
total = repo().aggregate(Notification, :count, :uuid)
rows =
Notification
|> order_by([n], desc: n.inserted_at)
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> repo().all()
|> repo().preload([:recipient, activity: [:actor]])
{rows, total}
rescue
_ -> {[], 0}
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, also marking it read. 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)
# Dismiss implies "handled", so also clear the unread flag (preserving an
# existing seen_at). Keeps the unread badge accurate, avoids bold rows in
# the Dismissed list, and Restore brings the item back as read.
notification
|> Ecto.Changeset.change(dismissed_at: now, seen_at: notification.seen_at || now)
|> repo().update()
|> broadcast_state(user_uuid, :notification_dismissed)
end
end
@doc "Restores (un-dismisses) a single notification. Idempotent."
def restore(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: nil} = notification ->
{:ok, notification}
%Notification{} = notification ->
notification
|> Ecto.Changeset.change(dismissed_at: nil)
|> repo().update()
# Reuses the dismissed-state-change event — consumers (bell, inbox) just
# reload, so a restored notification reappears in the active list/bell.
|> broadcast_state(user_uuid, :notification_dismissed)
end
end
@doc "Bulk-dismisses all undismissed notifications, also marking them read. Returns `{count, nil}`."
def dismiss_all(user_uuid) when is_binary(user_uuid) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
# Also clear the unread flag on dismiss (COALESCE preserves an existing
# seen_at), matching dismiss/2.
{count, _} =
Notification
|> where([n], n.recipient_uuid == ^user_uuid and is_nil(n.dismissed_at))
|> update([n],
set: [dismissed_at: ^now, seen_at: fragment("COALESCE(?, ?)", n.seen_at, ^now)]
)
|> repo().update_all([])
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: "Your own in-app notifications and per-type preferences"
}
end
@impl PhoenixKit.Module
def admin_tabs do
[
# Parent "Notifications" tab. Base "notifications" permission gates the
# personal pages (My Notifications + Notification Settings).
Tab.new!(
id: :admin_notifications,
label: "Notifications",
icon: "hero-bell",
path: "notifications",
priority: 640,
level: :admin,
permission: "notifications",
match: :prefix,
group: :admin_modules,
subtab_display: :when_active,
highlight_with_subtabs: false,
gettext_backend: PhoenixKitWeb.Gettext
),
# My Notifications shares the parent path. Exact-only regex so it stays
# lit on the bare inbox URL and does NOT prefix-match /settings
# (mirrors the :admin_users_manage precedent in AdminTabs).
Tab.new!(
id: :admin_notifications_mine,
label: "My Notifications",
icon: "hero-inbox",
path: "notifications",
priority: 641,
level: :admin,
permission: "notifications",
parent: :admin_notifications,
match: {:regex, ~r{^/admin/notifications$}},
gettext_backend: PhoenixKitWeb.Gettext
),
Tab.new!(
id: :admin_notifications_settings,
label: "My Settings",
icon: "hero-adjustments-horizontal",
path: "notifications/settings",
priority: 642,
level: :admin,
permission: "notifications",
parent: :admin_notifications,
match: :prefix,
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 ────────────────────────────────────────────────────────
# Resolve the dismissed-row filter. The explicit `:dismissed` opt wins; falls
# back to the legacy `:include_dismissed` bool for callers not yet updated.
defp dismissed_filter(opts) do
case Keyword.get(opts, :dismissed) do
nil -> if Keyword.get(opts, :include_dismissed, false), do: :include, else: :exclude
value -> value
end
end
defp filter_dismissed(query, :include), do: query
defp filter_dismissed(query, :only), do: where(query, [n], not is_nil(n.dismissed_at))
defp filter_dismissed(query, _exclude), 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