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

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…