Packages
phoenix_kit
1.7.89
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
221 Versions
Jump to
Current section
221 Versions
Compare versions
23
files changed
+1670
additions
-66
deletions
| @@ -1,3 +1,15 @@ | |
| 1 | + ## 1.7.89 - 2026-04-04 |
| 2 | + |
| 3 | + ### Added |
| 4 | + - Add V91 locations migration: location types, locations, and type assignments tables |
| 5 | + - Add JS hooks integration for parent app install and update workflows |
| 6 | + - Add organization accounts with person/organization user type and invitation flow |
| 7 | + - Add tax configuration consolidated into CountryData |
| 8 | + |
| 9 | + ### Changed |
| 10 | + - Remove hardcoded Billing and E-Commerce module cards in favor of auto-discovery |
| 11 | + - Update AGENTS.md with severity level definitions and JS hooks documentation |
| 12 | + |
| 1 13 | ## 1.7.88 - 2026-04-02 |
| 2 14 | |
| 3 15 | ### Changed |
| @@ -1,6 +1,6 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}. |
| 2 2 | {<<"name">>,<<"phoenix_kit">>}. |
| 3 | - {<<"version">>,<<"1.7.88">>}. |
| 3 | + {<<"version">>,<<"1.7.89">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105, |
| 6 6 | 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105, |
| @@ -142,6 +142,11 @@ | |
| 142 142 | <<"lib/phoenix_kit_web/live/users/users.html.heex">>, |
| 143 143 | <<"lib/phoenix_kit_web/live/users/media.ex">>, |
| 144 144 | <<"lib/phoenix_kit_web/live/users/roles.html.heex">>, |
| 145 | + <<"lib/phoenix_kit_web/live/activity">>, |
| 146 | + <<"lib/phoenix_kit_web/live/activity/index.ex">>, |
| 147 | + <<"lib/phoenix_kit_web/live/activity/show.ex">>, |
| 148 | + <<"lib/phoenix_kit_web/live/activity/show.html.heex">>, |
| 149 | + <<"lib/phoenix_kit_web/live/activity/index.html.heex">>, |
| 145 150 | <<"lib/phoenix_kit_web/live/settings.html.heex">>, |
| 146 151 | <<"lib/phoenix_kit_web/live/settings.ex">>, |
| 147 152 | <<"lib/phoenix_kit_web/live/modules.html.heex">>, |
| @@ -266,6 +271,7 @@ | |
| 266 271 | <<"lib/phoenix_kit/migrations/postgres/v65.ex">>, |
| 267 272 | <<"lib/phoenix_kit/migrations/postgres/v48.ex">>, |
| 268 273 | <<"lib/phoenix_kit/migrations/postgres/v17.ex">>, |
| 274 | + <<"lib/phoenix_kit/migrations/postgres/v90.ex">>, |
| 269 275 | <<"lib/phoenix_kit/migrations/postgres/v38.ex">>, |
| 270 276 | <<"lib/phoenix_kit/migrations/postgres/v67.ex">>, |
| 271 277 | <<"lib/phoenix_kit/migrations/postgres/v70.ex">>, |
| @@ -371,7 +377,10 @@ | |
| 371 377 | <<"lib/phoenix_kit/module_discovery.ex">>, |
| 372 378 | <<"lib/phoenix_kit/layout_config.ex">>,<<"lib/phoenix_kit/workers">>, |
| 373 379 | <<"lib/phoenix_kit/workers/oauth_config_loader.ex">>, |
| 374 | - <<"lib/phoenix_kit/mailer.ex">>,<<"lib/phoenix_kit/install">>, |
| 380 | + <<"lib/phoenix_kit/activity">>,<<"lib/phoenix_kit/activity/entry.ex">>, |
| 381 | + <<"lib/phoenix_kit/activity/prune_worker.ex">>, |
| 382 | + <<"lib/phoenix_kit/activity/activity.ex">>,<<"lib/phoenix_kit/mailer.ex">>, |
| 383 | + <<"lib/phoenix_kit/install">>, |
| 375 384 | <<"lib/phoenix_kit/install/application_supervisor.ex">>, |
| 376 385 | <<"lib/phoenix_kit/install/router_integration.ex">>, |
| 377 386 | <<"lib/phoenix_kit/install/repo_detection.ex">>, |
| @@ -0,0 +1,368 @@ | |
| 1 | + defmodule PhoenixKit.Activity do |
| 2 | + @moduledoc """ |
| 3 | + Activity feed for tracking business-level actions across the platform. |
| 4 | + |
| 5 | + Provides a simple API for logging and querying activities. Any module can call |
| 6 | + `Activity.log/1` to record an action. The admin dashboard shows a real-time |
| 7 | + activity stream. |
| 8 | + |
| 9 | + ## Usage |
| 10 | + |
| 11 | + PhoenixKit.Activity.log(%{ |
| 12 | + action: "post.created", |
| 13 | + actor_uuid: user.uuid, |
| 14 | + resource_type: "post", |
| 15 | + resource_uuid: post.uuid, |
| 16 | + metadata: %{"title" => post.title} |
| 17 | + }) |
| 18 | + |
| 19 | + ## Action naming convention |
| 20 | + |
| 21 | + Use dotted format: `resource.verb` — e.g., "post.created", "comment.liked", |
| 22 | + "user.registered", "password.changed", "role.assigned". |
| 23 | + """ |
| 24 | + |
| 25 | + import Ecto.Query, warn: false |
| 26 | + require Logger |
| 27 | + |
| 28 | + alias PhoenixKit.Activity.Entry |
| 29 | + alias PhoenixKit.PubSub.Manager, as: PubSubManager |
| 30 | + alias PhoenixKit.Settings |
| 31 | + |
| 32 | + @pubsub_topic "phoenix_kit:activity" |
| 33 | + |
| 34 | + @doc """ |
| 35 | + Logs an activity. |
| 36 | + |
| 37 | + ## Required fields |
| 38 | + |
| 39 | + - `:action` — dotted action string (e.g., "post.created") |
| 40 | + |
| 41 | + ## Optional fields |
| 42 | + |
| 43 | + - `:actor_uuid` — who performed the action |
| 44 | + - `:resource_type` — type of resource acted on |
| 45 | + - `:resource_uuid` — UUID of the resource |
| 46 | + - `:target_uuid` — who was affected (e.g., follow target) |
| 47 | + - `:metadata` — map of additional context |
| 48 | + |
| 49 | + Returns `{:ok, entry}` or `{:error, changeset}`. Failures are logged but never crash. |
| 50 | + """ |
| 51 | + def log(attrs) when is_map(attrs) do |
| 52 | + case %Entry{} |> Entry.changeset(attrs) |> repo().insert() do |
| 53 | + {:ok, entry} -> |
| 54 | + broadcast_activity(entry) |
| 55 | + {:ok, entry} |
| 56 | + |
| 57 | + {:error, changeset} -> |
| 58 | + Logger.warning("Failed to log activity: #{inspect(changeset.errors)}") |
| 59 | + {:error, changeset} |
| 60 | + end |
| 61 | + rescue |
| 62 | + e -> |
| 63 | + Logger.warning("Activity logging error: #{inspect(e)}") |
| 64 | + {:error, e} |
| 65 | + end |
| 66 | + |
| 67 | + @doc """ |
| 68 | + Logs a user change with automatic from/to diff extraction from a changeset. |
| 69 | + |
| 70 | + Extracts changed fields from the changeset and builds `field_from` / `field_to` |
| 71 | + metadata pairs. Skips logging if nothing actually changed. |
| 72 | + |
| 73 | + ## Options |
| 74 | + |
| 75 | + - `:actor_uuid` — who performed the action (default: the user's own UUID) |
| 76 | + - `:target_uuid` — who was affected (default: nil) |
| 77 | + - `:mode` — "auto" or "manual" (default: "auto") |
| 78 | + - `:actor_role` — "user" or "admin" (default: "user") |
| 79 | + - `:extra_metadata` — additional metadata to merge in (default: %{}) |
| 80 | + - `:skip_fields` — fields to exclude from diff (default: [:custom_fields]) |
| 81 | + """ |
| 82 | + def log_user_change( |
| 83 | + action, |
| 84 | + %{uuid: user_uuid} = user, |
| 85 | + %Ecto.Changeset{} = changeset, |
| 86 | + opts \\ [] |
| 87 | + ) do |
| 88 | + skip_fields = Keyword.get(opts, :skip_fields, [:custom_fields]) |
| 89 | + |
| 90 | + changed_fields = |
| 91 | + changeset.changes |
| 92 | + |> Map.drop(skip_fields) |
| 93 | + |> Enum.flat_map(fn {k, new_val} -> |
| 94 | + old_val = Map.get(user, k) |
| 95 | + [{"#{k}_from", to_string(old_val || "")}, {"#{k}_to", to_string(new_val)}] |
| 96 | + end) |
| 97 | + |> Map.new() |
| 98 | + |
| 99 | + if changed_fields == %{} do |
| 100 | + :noop |
| 101 | + else |
| 102 | + actor_uuid = Keyword.get(opts, :actor_uuid, user_uuid) |
| 103 | + target_uuid = Keyword.get(opts, :target_uuid) |
| 104 | + mode = Keyword.get(opts, :mode, "manual") |
| 105 | + actor_role = Keyword.get(opts, :actor_role, "user") |
| 106 | + extra = Keyword.get(opts, :extra_metadata, %{}) |
| 107 | + |
| 108 | + metadata = |
| 109 | + changed_fields |
| 110 | + |> Map.put("actor_role", actor_role) |
| 111 | + |> Map.merge(extra) |
| 112 | + |
| 113 | + log(%{ |
| 114 | + action: action, |
| 115 | + module: "users", |
| 116 | + mode: mode, |
| 117 | + actor_uuid: actor_uuid, |
| 118 | + resource_type: "user", |
| 119 | + resource_uuid: user_uuid, |
| 120 | + target_uuid: target_uuid, |
| 121 | + metadata: metadata |
| 122 | + }) |
| 123 | + end |
| 124 | + end |
| 125 | + |
| 126 | + @doc """ |
| 127 | + Lists activities with filtering and pagination. |
| 128 | + |
| 129 | + ## Options |
| 130 | + |
| 131 | + - `:action` — filter by action string (exact match or prefix with "post.*") |
| 132 | + - `:actor_uuid` — filter by who performed the action |
| 133 | + - `:resource_type` — filter by resource type |
| 134 | + - `:target_uuid` — filter by who was affected |
| 135 | + - `:since` — filter activities after this datetime |
| 136 | + - `:until` — filter activities before this datetime |
| 137 | + - `:page` — page number (default: 1) |
| 138 | + - `:per_page` — items per page (default: 50) |
| 139 | + - `:preload` — associations to preload (default: [:actor]) |
| 140 | + """ |
| 141 | + def list(opts \\ []) do |
| 142 | + page = Keyword.get(opts, :page, 1) |
| 143 | + per_page = Keyword.get(opts, :per_page, 50) |
| 144 | + preloads = Keyword.get(opts, :preload, [:actor]) |
| 145 | + |
| 146 | + query = |
| 147 | + Entry |
| 148 | + |> order_by([e], desc: e.inserted_at) |
| 149 | + |> apply_filters(opts) |
| 150 | + |
| 151 | + total = repo().aggregate(query, :count) |
| 152 | + |
| 153 | + entries = |
| 154 | + query |
| 155 | + |> limit(^per_page) |
| 156 | + |> offset(^((page - 1) * per_page)) |
| 157 | + |> repo().all() |
| 158 | + |> repo().preload(preloads) |
| 159 | + |
| 160 | + %{ |
| 161 | + entries: entries, |
| 162 | + total: total, |
| 163 | + page: page, |
| 164 | + per_page: per_page, |
| 165 | + total_pages: max(ceil(total / per_page), 1) |
| 166 | + } |
| 167 | + end |
| 168 | + |
| 169 | + @doc "Gets a single activity entry by UUID with preloaded associations." |
| 170 | + def get_entry(uuid) do |
| 171 | + case repo().get(Entry, uuid) do |
| 172 | + nil -> nil |
| 173 | + entry -> repo().preload(entry, [:actor, :target]) |
| 174 | + end |
| 175 | + end |
| 176 | + |
| 177 | + @doc "Gets a single activity entry by UUID. Raises if not found." |
| 178 | + def get_entry!(uuid) do |
| 179 | + Entry |
| 180 | + |> repo().get!(uuid) |
| 181 | + |> repo().preload([:actor, :target]) |
| 182 | + end |
| 183 | + |
| 184 | + @doc "Lists activities for a specific user (as actor)." |
| 185 | + def list_for_user(user_uuid, opts \\ []) do |
| 186 | + opts = Keyword.put(opts, :actor_uuid, user_uuid) |
| 187 | + list(opts) |
| 188 | + end |
| 189 | + |
| 190 | + @doc "Returns the N most recent activities." |
| 191 | + def recent(limit \\ 20) do |
| 192 | + Entry |
| 193 | + |> order_by([e], desc: e.inserted_at) |
| 194 | + |> limit(^limit) |
| 195 | + |> preload([:actor]) |
| 196 | + |> repo().all() |
| 197 | + end |
| 198 | + |
| 199 | + @doc "Counts activities matching the given filters." |
| 200 | + def count(opts \\ []) do |
| 201 | + Entry |
| 202 | + |> apply_filters(opts) |
| 203 | + |> repo().aggregate(:count) |
| 204 | + rescue |
| 205 | + _ -> 0 |
| 206 | + end |
| 207 | + |
| 208 | + @doc """ |
| 209 | + Resolves resource info for entries where `resource_type` is "user". |
| 210 | + |
| 211 | + Returns a map of `resource_uuid => %{email: ..., first_name: ..., last_name: ...}`. |
| 212 | + Batch-queries to avoid N+1. |
| 213 | + """ |
| 214 | + def resolve_resource_users(entries) do |
| 215 | + user_uuids = |
| 216 | + entries |
| 217 | + |> Enum.filter(&(&1.resource_type == "user" && &1.resource_uuid)) |
| 218 | + |> Enum.map(& &1.resource_uuid) |
| 219 | + |> Enum.uniq() |
| 220 | + |
| 221 | + if user_uuids == [] do |
| 222 | + %{} |
| 223 | + else |
| 224 | + from(u in PhoenixKit.Users.Auth.User, |
| 225 | + where: u.uuid in ^user_uuids, |
| 226 | + select: {u.uuid, %{email: u.email, first_name: u.first_name, last_name: u.last_name}} |
| 227 | + ) |
| 228 | + |> repo().all() |
| 229 | + |> Map.new() |
| 230 | + end |
| 231 | + rescue |
| 232 | + _ -> %{} |
| 233 | + end |
| 234 | + |
| 235 | + @doc "Returns distinct modes that have been logged." |
| 236 | + def list_modes do |
| 237 | + from(e in Entry, |
| 238 | + distinct: true, |
| 239 | + select: e.mode, |
| 240 | + where: not is_nil(e.mode), |
| 241 | + order_by: e.mode |
| 242 | + ) |
| 243 | + |> repo().all() |
| 244 | + rescue |
| 245 | + _ -> [] |
| 246 | + end |
| 247 | + |
| 248 | + @doc "Returns distinct modules that have been logged." |
| 249 | + def list_modules do |
| 250 | + from(e in Entry, |
| 251 | + distinct: true, |
| 252 | + select: e.module, |
| 253 | + where: not is_nil(e.module), |
| 254 | + order_by: e.module |
| 255 | + ) |
| 256 | + |> repo().all() |
| 257 | + rescue |
| 258 | + _ -> [] |
| 259 | + end |
| 260 | + |
| 261 | + @doc "Returns distinct action types that have been logged." |
| 262 | + def list_action_types do |
| 263 | + from(e in Entry, distinct: true, select: e.action, order_by: e.action) |
| 264 | + |> repo().all() |
| 265 | + rescue |
| 266 | + _ -> [] |
| 267 | + end |
| 268 | + |
| 269 | + @doc "Returns distinct resource types that have been logged." |
| 270 | + def list_resource_types do |
| 271 | + from(e in Entry, |
| 272 | + distinct: true, |
| 273 | + select: e.resource_type, |
| 274 | + where: not is_nil(e.resource_type), |
| 275 | + order_by: e.resource_type |
| 276 | + ) |
| 277 | + |> repo().all() |
| 278 | + rescue |
| 279 | + _ -> [] |
| 280 | + end |
| 281 | + |
| 282 | + @doc "Deletes activities older than the given number of days." |
| 283 | + def prune(days) when is_integer(days) and days > 0 do |
| 284 | + cutoff = DateTime.add(DateTime.utc_now(), -days * 86_400, :second) |
| 285 | + |
| 286 | + {count, _} = |
| 287 | + from(e in Entry, where: e.inserted_at < ^cutoff) |
| 288 | + |> repo().delete_all() |
| 289 | + |
| 290 | + Logger.info("Pruned #{count} activities older than #{days} days") |
| 291 | + {:ok, count} |
| 292 | + end |
| 293 | + |
| 294 | + @doc "Returns the configured retention period in days." |
| 295 | + def retention_days do |
| 296 | + case Settings.get_setting("activity_retention_days", "90") do |
| 297 | + val when is_binary(val) -> |
| 298 | + case Integer.parse(val) do |
| 299 | + {n, _} -> n |
| 300 | + :error -> 90 |
| 301 | + end |
| 302 | + |
| 303 | + _ -> |
| 304 | + 90 |
| 305 | + end |
| 306 | + rescue |
| 307 | + _ -> 90 |
| 308 | + end |
| 309 | + |
| 310 | + @doc "PubSub topic for activity events." |
| 311 | + def pubsub_topic, do: @pubsub_topic |
| 312 | + |
| 313 | + # Private |
| 314 | + |
| 315 | + defp apply_filters(query, opts) do |
| 316 | + query |
| 317 | + |> maybe_filter_module(Keyword.get(opts, :module)) |
| 318 | + |> maybe_filter_mode(Keyword.get(opts, :mode)) |
| 319 | + |> maybe_filter_action(Keyword.get(opts, :action)) |
| 320 | + |> maybe_filter_actor(Keyword.get(opts, :actor_uuid)) |
| 321 | + |> maybe_filter_resource_type(Keyword.get(opts, :resource_type)) |
| 322 | + |> maybe_filter_target(Keyword.get(opts, :target_uuid)) |
| 323 | + |> maybe_filter_since(Keyword.get(opts, :since)) |
| 324 | + |> maybe_filter_until(Keyword.get(opts, :until)) |
| 325 | + end |
| 326 | + |
| 327 | + defp maybe_filter_module(query, nil), do: query |
| 328 | + defp maybe_filter_module(query, mod), do: where(query, [e], e.module == ^mod) |
| 329 | + |
| 330 | + defp maybe_filter_mode(query, nil), do: query |
| 331 | + defp maybe_filter_mode(query, mode), do: where(query, [e], e.mode == ^mode) |
| 332 | + |
| 333 | + defp maybe_filter_action(query, nil), do: query |
| 334 | + |
| 335 | + defp maybe_filter_action(query, action) do |
| 336 | + if String.ends_with?(action, ".*") do |
| 337 | + prefix = String.trim_trailing(action, ".*") <> "." |
| 338 | + where(query, [e], like(e.action, ^"#{prefix}%")) |
| 339 | + else |
| 340 | + where(query, [e], e.action == ^action) |
| 341 | + end |
| 342 | + end |
| 343 | + |
| 344 | + defp maybe_filter_actor(query, nil), do: query |
| 345 | + defp maybe_filter_actor(query, uuid), do: where(query, [e], e.actor_uuid == ^uuid) |
| 346 | + |
| 347 | + defp maybe_filter_resource_type(query, nil), do: query |
| 348 | + defp maybe_filter_resource_type(query, type), do: where(query, [e], e.resource_type == ^type) |
| 349 | + |
| 350 | + defp maybe_filter_target(query, nil), do: query |
| 351 | + defp maybe_filter_target(query, uuid), do: where(query, [e], e.target_uuid == ^uuid) |
| 352 | + |
| 353 | + defp maybe_filter_since(query, nil), do: query |
| 354 | + defp maybe_filter_since(query, dt), do: where(query, [e], e.inserted_at >= ^dt) |
| 355 | + |
| 356 | + defp maybe_filter_until(query, nil), do: query |
| 357 | + defp maybe_filter_until(query, dt), do: where(query, [e], e.inserted_at <= ^dt) |
| 358 | + |
| 359 | + defp broadcast_activity(entry) do |
| 360 | + PubSubManager.broadcast(@pubsub_topic, {:activity_logged, entry}) |
| 361 | + rescue |
| 362 | + _ -> :ok |
| 363 | + end |
| 364 | + |
| 365 | + defp repo do |
| 366 | + PhoenixKit.RepoHelper.repo() |
| 367 | + end |
| 368 | + end |
| @@ -0,0 +1,79 @@ | |
| 1 | + defmodule PhoenixKit.Activity.Entry do |
| 2 | + @moduledoc """ |
| 3 | + Schema for activity feed entries. |
| 4 | + |
| 5 | + Records business-level actions across the platform: posts created, comments liked, |
| 6 | + users followed, passwords changed, etc. |
| 7 | + |
| 8 | + ## Fields |
| 9 | + |
| 10 | + - `action` — Dotted action string: "post.created", "comment.liked", "user.registered" |
| 11 | + - `actor_uuid` — Who performed the action (FK to users) |
| 12 | + - `resource_type` — What kind of thing was acted on: "post", "comment", "user" |
| 13 | + - `resource_uuid` — UUID of the resource |
| 14 | + - `target_uuid` — Optional: who was affected (e.g., follow target, message recipient) |
| 15 | + - `metadata` — Flexible JSONB context (title, old_value, new_value, etc.) |
| 16 | + """ |
| 17 | + use Ecto.Schema |
| 18 | + import Ecto.Changeset |
| 19 | + |
| 20 | + @primary_key {:uuid, UUIDv7, autogenerate: true} |
| 21 | + |
| 22 | + @type t :: %__MODULE__{ |
| 23 | + uuid: UUIDv7.t() | nil, |
| 24 | + action: String.t(), |
| 25 | + actor_uuid: UUIDv7.t() | nil, |
| 26 | + resource_type: String.t() | nil, |
| 27 | + resource_uuid: Ecto.UUID.t() | nil, |
| 28 | + target_uuid: UUIDv7.t() | nil, |
| 29 | + metadata: map(), |
| 30 | + actor: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t() | nil, |
| 31 | + target: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t() | nil, |
| 32 | + inserted_at: DateTime.t() | nil |
| 33 | + } |
| 34 | + |
| 35 | + schema "phoenix_kit_activities" do |
| 36 | + field(:action, :string) |
| 37 | + field(:module, :string) |
| 38 | + field(:mode, :string) |
| 39 | + field(:resource_type, :string) |
| 40 | + field(:resource_uuid, Ecto.UUID) |
| 41 | + field(:metadata, :map, default: %{}) |
| 42 | + |
| 43 | + belongs_to(:actor, PhoenixKit.Users.Auth.User, |
| 44 | + foreign_key: :actor_uuid, |
| 45 | + references: :uuid, |
| 46 | + type: UUIDv7 |
| 47 | + ) |
| 48 | + |
| 49 | + belongs_to(:target, PhoenixKit.Users.Auth.User, |
| 50 | + foreign_key: :target_uuid, |
| 51 | + references: :uuid, |
| 52 | + type: UUIDv7 |
| 53 | + ) |
| 54 | + |
| 55 | + timestamps(type: :utc_datetime, updated_at: false) |
| 56 | + end |
| 57 | + |
| 58 | + @doc "Changeset for creating an activity entry." |
| 59 | + def changeset(entry, attrs) do |
| 60 | + entry |
| 61 | + |> cast(attrs, [ |
| 62 | + :action, |
| 63 | + :module, |
| 64 | + :mode, |
| 65 | + :actor_uuid, |
| 66 | + :resource_type, |
| 67 | + :resource_uuid, |
| 68 | + :target_uuid, |
| 69 | + :metadata |
| 70 | + ]) |
| 71 | + |> validate_required([:action]) |
| 72 | + |> validate_length(:action, min: 1, max: 100) |
| 73 | + |> validate_length(:module, max: 50) |
| 74 | + |> validate_length(:mode, max: 20) |
| 75 | + |> validate_length(:resource_type, max: 50) |
| 76 | + |> foreign_key_constraint(:actor_uuid) |
| 77 | + |> foreign_key_constraint(:target_uuid) |
| 78 | + end |
| 79 | + end |
| @@ -0,0 +1,20 @@ | |
| 1 | + defmodule PhoenixKit.Activity.PruneWorker do |
| 2 | + @moduledoc """ |
| 3 | + Oban worker that prunes old activity entries based on the configured retention period. |
| 4 | + |
| 5 | + Runs daily. Retention is configured via the `activity_retention_days` setting (default: 90). |
| 6 | + """ |
| 7 | + |
| 8 | + use Oban.Worker, |
| 9 | + queue: :default, |
| 10 | + max_attempts: 3 |
| 11 | + |
| 12 | + alias PhoenixKit.Activity |
| 13 | + |
| 14 | + @impl Oban.Worker |
| 15 | + def perform(_job) do |
| 16 | + days = Activity.retention_days() |
| 17 | + Activity.prune(days) |
| 18 | + :ok |
| 19 | + end |
| 20 | + end |
Loading more files…