Packages

phoenix_kit

1.7.121
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.
"""
import Ecto.Query, warn: false
require Logger
alias PhoenixKit.Activity.Entry
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
# ── 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
# ── Settings ─────────────────────────────────────────────────────────
@doc "Is the notifications feature enabled? Default `true`."
def enabled? do
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