Packages
phoenix_kit
1.7.99
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
Current section
Files
lib/phoenix_kit/activity/activity.ex
defmodule PhoenixKit.Activity do
@moduledoc """
Activity feed for tracking business-level actions across the platform.
Provides a simple API for logging and querying activities. Any module can call
`Activity.log/1` to record an action. The admin dashboard shows a real-time
activity stream.
## Usage
PhoenixKit.Activity.log(%{
action: "post.created",
actor_uuid: user.uuid,
resource_type: "post",
resource_uuid: post.uuid,
metadata: %{"title" => post.title}
})
## Action naming convention
Use dotted format: `resource.verb` — e.g., "post.created", "comment.liked",
"user.registered", "password.changed", "role.assigned".
"""
import Ecto.Query, warn: false
require Logger
alias PhoenixKit.Activity.Entry
alias PhoenixKit.PubSub.Manager, as: PubSubManager
alias PhoenixKit.Settings
@pubsub_topic "phoenix_kit:activity"
@doc """
Logs an activity.
## Required fields
- `:action` — dotted action string (e.g., "post.created")
## Optional fields
- `:actor_uuid` — who performed the action
- `:resource_type` — type of resource acted on
- `:resource_uuid` — UUID of the resource
- `:target_uuid` — who was affected (e.g., follow target)
- `:metadata` — map of additional context
Returns `{:ok, entry}` or `{:error, changeset}`. Failures are logged but never crash.
"""
def log(attrs) when is_map(attrs) do
case %Entry{} |> Entry.changeset(attrs) |> repo().insert() do
{:ok, entry} ->
broadcast_activity(entry)
{:ok, entry}
{:error, changeset} ->
Logger.warning("Failed to log activity: #{inspect(changeset.errors)}")
{:error, changeset}
end
rescue
e ->
Logger.warning("Activity logging error: #{inspect(e)}")
{:error, e}
end
@doc """
Logs a user change with automatic from/to diff extraction from a changeset.
Extracts changed fields from the changeset and builds `field_from` / `field_to`
metadata pairs. Skips logging if nothing actually changed.
## Options
- `:actor_uuid` — who performed the action (default: the user's own UUID)
- `:target_uuid` — who was affected (default: nil)
- `:mode` — "auto" or "manual" (default: "auto")
- `:actor_role` — "user" or "admin" (default: "user")
- `:extra_metadata` — additional metadata to merge in (default: %{})
- `:skip_fields` — fields to exclude from diff (default: [:custom_fields])
"""
def log_user_change(
action,
%{uuid: user_uuid} = user,
%Ecto.Changeset{} = changeset,
opts \\ []
) do
skip_fields = Keyword.get(opts, :skip_fields, [:custom_fields])
changed_fields =
changeset.changes
|> Map.drop(skip_fields)
|> Enum.flat_map(fn {k, new_val} ->
old_val = Map.get(user, k)
[{"#{k}_from", to_string(old_val || "")}, {"#{k}_to", to_string(new_val)}]
end)
|> Map.new()
if changed_fields == %{} do
:noop
else
actor_uuid = Keyword.get(opts, :actor_uuid, user_uuid)
target_uuid = Keyword.get(opts, :target_uuid)
mode = Keyword.get(opts, :mode, "manual")
actor_role = Keyword.get(opts, :actor_role, "user")
extra = Keyword.get(opts, :extra_metadata, %{})
metadata =
changed_fields
|> Map.put("actor_role", actor_role)
|> Map.merge(extra)
log(%{
action: action,
module: "users",
mode: mode,
actor_uuid: actor_uuid,
resource_type: "user",
resource_uuid: user_uuid,
target_uuid: target_uuid,
metadata: metadata
})
end
end
@doc """
Lists activities with filtering and pagination.
## Options
- `:action` — filter by action string (exact match or prefix with "post.*")
- `:actor_uuid` — filter by who performed the action
- `:resource_type` — filter by resource type
- `:target_uuid` — filter by who was affected
- `:since` — filter activities after this datetime
- `:until` — filter activities before this datetime
- `:page` — page number (default: 1)
- `:per_page` — items per page (default: 50)
- `:preload` — associations to preload (default: [:actor])
"""
def list(opts \\ []) do
page = Keyword.get(opts, :page, 1)
per_page = Keyword.get(opts, :per_page, 50)
preloads = Keyword.get(opts, :preload, [:actor])
query =
Entry
|> order_by([e], desc: e.inserted_at)
|> apply_filters(opts)
total = repo().aggregate(query, :count)
entries =
query
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> repo().all()
|> repo().preload(preloads)
%{
entries: entries,
total: total,
page: page,
per_page: per_page,
total_pages: max(ceil(total / per_page), 1)
}
end
@doc "Gets a single activity entry by UUID with preloaded associations."
def get_entry(uuid) do
case repo().get(Entry, uuid) do
nil -> nil
entry -> repo().preload(entry, [:actor, :target])
end
end
@doc "Gets a single activity entry by UUID. Raises if not found."
def get_entry!(uuid) do
Entry
|> repo().get!(uuid)
|> repo().preload([:actor, :target])
end
@doc "Lists activities for a specific user (as actor)."
def list_for_user(user_uuid, opts \\ []) do
opts = Keyword.put(opts, :actor_uuid, user_uuid)
list(opts)
end
@doc "Returns the N most recent activities."
def recent(limit \\ 20) do
Entry
|> order_by([e], desc: e.inserted_at)
|> limit(^limit)
|> preload([:actor])
|> repo().all()
end
@doc "Counts activities matching the given filters."
def count(opts \\ []) do
Entry
|> apply_filters(opts)
|> repo().aggregate(:count)
rescue
_ -> 0
end
@doc """
Resolves resource info for entries where `resource_type` is "user".
Returns a map of `resource_uuid => %{email: ..., first_name: ..., last_name: ...}`.
Batch-queries to avoid N+1.
"""
def resolve_resource_users(entries) do
user_uuids =
entries
|> Enum.filter(&(&1.resource_type == "user" && &1.resource_uuid))
|> Enum.map(& &1.resource_uuid)
|> Enum.uniq()
if user_uuids == [] do
%{}
else
from(u in PhoenixKit.Users.Auth.User,
where: u.uuid in ^user_uuids,
select: {u.uuid, %{email: u.email, first_name: u.first_name, last_name: u.last_name}}
)
|> repo().all()
|> Map.new()
end
rescue
_ -> %{}
end
@doc "Returns distinct modes that have been logged."
def list_modes do
from(e in Entry,
distinct: true,
select: e.mode,
where: not is_nil(e.mode),
order_by: e.mode
)
|> repo().all()
rescue
_ -> []
end
@doc "Returns distinct modules that have been logged."
def list_modules do
from(e in Entry,
distinct: true,
select: e.module,
where: not is_nil(e.module),
order_by: e.module
)
|> repo().all()
rescue
_ -> []
end
@doc "Returns distinct action types that have been logged."
def list_action_types do
from(e in Entry, distinct: true, select: e.action, order_by: e.action)
|> repo().all()
rescue
_ -> []
end
@doc "Returns distinct resource types that have been logged."
def list_resource_types do
from(e in Entry,
distinct: true,
select: e.resource_type,
where: not is_nil(e.resource_type),
order_by: e.resource_type
)
|> repo().all()
rescue
_ -> []
end
@doc "Deletes activities older than the given number of days."
def prune(days) when is_integer(days) and days > 0 do
cutoff = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
{count, _} =
from(e in Entry, where: e.inserted_at < ^cutoff)
|> repo().delete_all()
Logger.info("Pruned #{count} activities older than #{days} days")
{:ok, count}
end
@doc "Returns the configured retention period in days."
def retention_days do
case Settings.get_setting("activity_retention_days", "90") do
val when is_binary(val) ->
case Integer.parse(val) do
{n, _} -> n
:error -> 90
end
_ ->
90
end
rescue
_ -> 90
end
@doc "PubSub topic for activity events."
def pubsub_topic, do: @pubsub_topic
@doc "Returns a CSS badge class based on the action verb."
def action_badge_color(action) do
cond do
String.contains?(action, "created") ->
"badge-success"
String.contains?(action, "deleted") ->
"badge-error"
String.contains?(action, "updated") or String.contains?(action, "changed") ->
"badge-warning"
String.contains?(action, "liked") or String.contains?(action, "followed") ->
"badge-info"
true ->
"badge-ghost"
end
end
@doc "Returns a CSS badge class based on the mode."
def mode_badge_color("manual"), do: "badge-warning"
def mode_badge_color("auto"), do: "badge-info"
def mode_badge_color("cron"), do: "badge-secondary"
def mode_badge_color("script"), do: "badge-accent"
def mode_badge_color(_), do: "badge-ghost"
# Private
defp apply_filters(query, opts) do
query
|> maybe_filter_module(Keyword.get(opts, :module))
|> maybe_filter_mode(Keyword.get(opts, :mode))
|> maybe_filter_action(Keyword.get(opts, :action))
|> maybe_filter_actor(Keyword.get(opts, :actor_uuid))
|> maybe_filter_resource_type(Keyword.get(opts, :resource_type))
|> maybe_filter_target(Keyword.get(opts, :target_uuid))
|> maybe_filter_since(Keyword.get(opts, :since))
|> maybe_filter_until(Keyword.get(opts, :until))
end
defp maybe_filter_module(query, nil), do: query
defp maybe_filter_module(query, mod), do: where(query, [e], e.module == ^mod)
defp maybe_filter_mode(query, nil), do: query
defp maybe_filter_mode(query, mode), do: where(query, [e], e.mode == ^mode)
defp maybe_filter_action(query, nil), do: query
defp maybe_filter_action(query, action) do
if String.ends_with?(action, ".*") do
prefix = String.trim_trailing(action, ".*") <> "."
where(query, [e], like(e.action, ^"#{prefix}%"))
else
where(query, [e], e.action == ^action)
end
end
defp maybe_filter_actor(query, nil), do: query
defp maybe_filter_actor(query, uuid), do: where(query, [e], e.actor_uuid == ^uuid)
defp maybe_filter_resource_type(query, nil), do: query
defp maybe_filter_resource_type(query, type), do: where(query, [e], e.resource_type == ^type)
defp maybe_filter_target(query, nil), do: query
defp maybe_filter_target(query, uuid), do: where(query, [e], e.target_uuid == ^uuid)
defp maybe_filter_since(query, nil), do: query
defp maybe_filter_since(query, dt), do: where(query, [e], e.inserted_at >= ^dt)
defp maybe_filter_until(query, nil), do: query
defp maybe_filter_until(query, dt), do: where(query, [e], e.inserted_at <= ^dt)
defp broadcast_activity(entry) do
PubSubManager.broadcast(@pubsub_topic, {:activity_logged, entry})
rescue
_ -> :ok
end
defp repo do
PhoenixKit.RepoHelper.repo()
end
end