From b4ccc79de20c169530d9af522e82e7c61b54583f Mon Sep 17 00:00:00 2001 From: timujeen Date: Sat, 14 Mar 2026 22:14:52 +0000 Subject: [PATCH 01/10] Add required_modules/0 optional callback to PhoenixKit.Module --- lib/phoenix_kit/module.ex | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/phoenix_kit/module.ex b/lib/phoenix_kit/module.ex index 7b99b4b01..f8d18ddbb 100644 --- a/lib/phoenix_kit/module.ex +++ b/lib/phoenix_kit/module.ex @@ -107,6 +107,7 @@ defmodule PhoenixKit.Module do @callback route_module() :: module() | nil @callback version() :: String.t() @callback migration_module() :: module() | nil + @callback required_modules() :: [String.t()] @optional_callbacks [ get_config: 0, @@ -117,7 +118,8 @@ defmodule PhoenixKit.Module do children: 0, route_module: 0, version: 0, - migration_module: 0 + migration_module: 0, + required_modules: 0 ] defmacro __using__(_opts) do @@ -157,6 +159,9 @@ defmodule PhoenixKit.Module do @impl PhoenixKit.Module def migration_module, do: nil + @impl PhoenixKit.Module + def required_modules, do: [] + defoverridable get_config: 0, permission_metadata: 0, admin_tabs: 0, @@ -165,7 +170,8 @@ defmodule PhoenixKit.Module do children: 0, route_module: 0, version: 0, - migration_module: 0 + migration_module: 0, + required_modules: 0 end end end From 76837f5216f78a8b16658136d41434a87bd4e8ee Mon Sep 17 00:00:00 2001 From: timujeen Date: Sat, 14 Mar 2026 22:27:44 +0000 Subject: [PATCH 02/10] Show dependency warnings on Modules page when required modules are disabled --- lib/phoenix_kit/module_registry.ex | 52 ++++++++++++++++++++++ lib/phoenix_kit_web/live/modules.ex | 16 ++++++- lib/phoenix_kit_web/live/modules.html.heex | 16 +++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/lib/phoenix_kit/module_registry.ex b/lib/phoenix_kit/module_registry.ex index 4b426c41d..02ffc92d7 100644 --- a/lib/phoenix_kit/module_registry.ex +++ b/lib/phoenix_kit/module_registry.ex @@ -181,6 +181,38 @@ defmodule PhoenixKit.ModuleRegistry do end) end + @doc """ + Returns dependency warnings for the Modules page. + + Each warning is a map: + %{module: module(), module_name: String.t(), requires_key: String.t()} + + Called on Modules page render — computes live (not hot path). + """ + @spec dependency_warnings() :: [map()] + def dependency_warnings do + enabled_keys = + enabled_modules() + |> Enum.map(&safe_call(&1, :module_key, nil)) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + all_modules() + |> Enum.filter(&safe_enabled?/1) + |> Enum.flat_map(fn mod -> + required = safe_call(mod, :required_modules, []) + missing = Enum.reject(required, &MapSet.member?(enabled_keys, &1)) + + Enum.map(missing, fn req_key -> + %{ + module: mod, + module_name: safe_call(mod, :module_name, inspect(mod)), + requires_key: req_key + } + end) + end) + end + @doc "Returns all feature module key strings from registered modules." @spec all_feature_keys() :: [String.t()] def all_feature_keys do @@ -293,6 +325,7 @@ defmodule PhoenixKit.ModuleRegistry do all_tabs = Enum.flat_map(modules, &safe_call(&1, :admin_tabs, [])) warn_duplicate_tab_ids(all_tabs) warn_tabs_missing_permission(modules) + validate_module_dependencies(modules) end # Validate a single module being registered at runtime. @@ -413,6 +446,25 @@ defmodule PhoenixKit.ModuleRegistry do end end + defp validate_module_dependencies(modules) do + all_keys = + modules + |> Enum.map(&safe_call(&1, :module_key, nil)) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + for mod <- modules do + required = safe_call(mod, :required_modules, []) + + for req_key <- required, not MapSet.member?(all_keys, req_key) do + Logger.warning( + "[ModuleRegistry] #{inspect(mod)} requires module #{inspect(req_key)} " <> + "which is not registered. This module may not function correctly." + ) + end + end + end + # Safely call an optional callback on a module, returning the default # if the module isn't loaded or doesn't export the function. @spec safe_call(module(), atom(), term()) :: term() diff --git a/lib/phoenix_kit_web/live/modules.ex b/lib/phoenix_kit_web/live/modules.ex index e1b78dc00..dec2bf10b 100644 --- a/lib/phoenix_kit_web/live/modules.ex +++ b/lib/phoenix_kit_web/live/modules.ex @@ -30,6 +30,7 @@ defmodule PhoenixKitWeb.Live.Modules do accessible = if scope, do: Scope.accessible_modules(scope), else: MapSet.new() external_modules = load_external_modules(module_configs) + dep_warnings = ModuleRegistry.dependency_warnings() socket = socket @@ -38,6 +39,7 @@ defmodule PhoenixKitWeb.Live.Modules do |> assign(:accessible_modules, accessible) |> assign(:module_configs, module_configs) |> assign(:external_modules, external_modules) + |> assign(:dep_warnings, dep_warnings) {:ok, socket} end @@ -59,11 +61,21 @@ defmodule PhoenixKitWeb.Live.Modules do # ============================================================================ def handle_info({:module_enabled, module_key}, socket) do - {:noreply, reload_module_config(socket, module_key)} + socket = + socket + |> reload_module_config(module_key) + |> assign(:dep_warnings, ModuleRegistry.dependency_warnings()) + + {:noreply, socket} end def handle_info({:module_disabled, module_key}, socket) do - {:noreply, reload_module_config(socket, module_key)} + socket = + socket + |> reload_module_config(module_key) + |> assign(:dep_warnings, ModuleRegistry.dependency_warnings()) + + {:noreply, socket} end def handle_info(_msg, socket), do: {:noreply, socket} diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex index f536ef96b..9bfd6c36d 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -13,6 +13,22 @@ subtitle="Manage modules" /> + <%!-- Dependency warnings --%> +
+
+ <.icon name="hero-exclamation-triangle" class="w-4 h-4 shrink-0" /> + + {w.module_name} + requires the {w.requires_key} + module to be enabled. + Some features may not work correctly. + +
+
+
<%!-- Storage Module --%> <%= if "storage" in @accessible_modules do %> From 0a56ad425ae85643472cf35ffb61870e98226070 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Sat, 14 Mar 2026 22:37:31 +0000 Subject: [PATCH 03/10] Fix module_test: replace Mailing with Newsletters Module was renamed from PhoenixKit.Modules.Mailing to PhoenixKit.Modules.Newsletters but the smoke test list was not updated, causing setup_all to fail on Code.ensure_loaded!/1. --- test/phoenix_kit/module_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/phoenix_kit/module_test.exs b/test/phoenix_kit/module_test.exs index 086e61471..01f43f22b 100644 --- a/test/phoenix_kit/module_test.exs +++ b/test/phoenix_kit/module_test.exs @@ -24,7 +24,7 @@ defmodule PhoenixKit.ModuleTest do PhoenixKit.Modules.Storage, PhoenixKit.Modules.Sync, PhoenixKit.Modules.CustomerService, - PhoenixKit.Modules.Mailing, + PhoenixKit.Modules.Newsletters, PhoenixKit.Jobs ] From ae3b9205b7126c67af7bff8dad53704936e1fd3e Mon Sep 17 00:00:00 2001 From: timujeen Date: Sat, 14 Mar 2026 22:41:10 +0000 Subject: [PATCH 04/10] Add auto-discovery for external module public routes via route_module/0 --- lib/phoenix_kit_web/integration.ex | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 53dba102c..ebdf2f3f6 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -1074,6 +1074,44 @@ defmodule PhoenixKitWeb.Integration do end) end + # Auto-discover public (non-admin) routes from external PhoenixKit modules. + # For each external module that implements route_module/0, calls generate(url_prefix) + # on the returned route module. This replaces hardcoded safe_route_call() lines when + # internal modules are extracted to separate packages. + defp compile_module_public_routes(url_prefix) do + PhoenixKit.ModuleDiscovery.discover_external_modules() + |> Enum.flat_map(fn mod -> + case Code.ensure_compiled(mod) do + {:module, _} -> + if function_exported?(mod, :route_module, 0) do + route_mod = mod.route_module() + compile_route_module_generate(route_mod, url_prefix) + else + [] + end + + _ -> + [] + end + end) + end + + defp compile_route_module_generate(nil, _url_prefix), do: [] + + defp compile_route_module_generate(route_mod, url_prefix) do + case Code.ensure_compiled(route_mod) do + {:module, _} -> + if function_exported?(route_mod, :generate, 1) do + normalize_routes(route_mod.generate(url_prefix)) + else + [] + end + + _ -> + [] + end + end + defp collect_module_tabs(mod, callback) do alias PhoenixKit.Dashboard.Tab context = tab_callback_context(callback) @@ -1315,6 +1353,9 @@ defmodule PhoenixKitWeb.Integration do # External route modules with public/non-admin routes external_public_routes = compile_external_public_routes(url_prefix) + # Auto-discovered public routes from external PhoenixKit modules + module_public_routes = compile_module_public_routes(url_prefix) + quote do # Generate pipeline definitions unquote(generate_pipelines()) @@ -1340,6 +1381,9 @@ defmodule PhoenixKitWeb.Integration do # External route modules with public routes unquote_splicing(external_public_routes) + # Auto-discovered public routes from external modules (e.g., extracted newsletters unsubscribe) + unquote_splicing(module_public_routes) + # Generate catch-all route for pages at root level (must be last) unquote(generate_pages_catch_all()) end From 7099412eb2d43b94d5cc32aa1b9d886c686e7cb4 Mon Sep 17 00:00:00 2001 From: timujeen Date: Sat, 14 Mar 2026 23:16:58 +0000 Subject: [PATCH 05/10] =?UTF-8?q?Remove=20Newsletters=20module=20=E2=80=94?= =?UTF-8?q?=20extracted=20to=20phoenix=5Fkit=5Fnewsletters=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/emails/sqs_processor.ex | 34 +- lib/modules/newsletters/broadcast.ex | 80 ---- lib/modules/newsletters/broadcaster.ex | 115 ------ lib/modules/newsletters/delivery.ex | 56 --- lib/modules/newsletters/list.ex | 65 ---- lib/modules/newsletters/list_member.ex | 50 --- lib/modules/newsletters/newsletters.ex | 367 ------------------ .../newsletters/web/broadcast_details.ex | 147 ------- .../web/broadcast_details.html.heex | 223 ----------- .../newsletters/web/broadcast_editor.ex | 259 ------------ .../web/broadcast_editor.html.heex | 137 ------- lib/modules/newsletters/web/broadcasts.ex | 80 ---- .../newsletters/web/broadcasts.html.heex | 149 ------- lib/modules/newsletters/web/list_editor.ex | 85 ---- .../newsletters/web/list_editor.html.heex | 105 ----- lib/modules/newsletters/web/list_members.ex | 205 ---------- .../newsletters/web/list_members.html.heex | 247 ------------ lib/modules/newsletters/web/lists.ex | 86 ---- lib/modules/newsletters/web/lists.html.heex | 182 --------- .../newsletters/web/unsubscribe_controller.ex | 63 --- .../newsletters/web/unsubscribe_html.ex | 5 - .../unsubscribe_html/unsubscribe.html.heex | 54 --- .../newsletters/workers/delivery_worker.ex | 164 -------- lib/phoenix_kit/module_registry.ex | 1 - .../workers/process_scheduled_jobs_worker.ex | 9 +- lib/phoenix_kit_web/integration.ex | 5 - lib/phoenix_kit_web/routes/newsletters.ex | 74 ---- test/phoenix_kit/module_registry_test.exs | 4 +- test/phoenix_kit/users/permissions_test.exs | 6 +- 29 files changed, 32 insertions(+), 3025 deletions(-) delete mode 100644 lib/modules/newsletters/broadcast.ex delete mode 100644 lib/modules/newsletters/broadcaster.ex delete mode 100644 lib/modules/newsletters/delivery.ex delete mode 100644 lib/modules/newsletters/list.ex delete mode 100644 lib/modules/newsletters/list_member.ex delete mode 100644 lib/modules/newsletters/newsletters.ex delete mode 100644 lib/modules/newsletters/web/broadcast_details.ex delete mode 100644 lib/modules/newsletters/web/broadcast_details.html.heex delete mode 100644 lib/modules/newsletters/web/broadcast_editor.ex delete mode 100644 lib/modules/newsletters/web/broadcast_editor.html.heex delete mode 100644 lib/modules/newsletters/web/broadcasts.ex delete mode 100644 lib/modules/newsletters/web/broadcasts.html.heex delete mode 100644 lib/modules/newsletters/web/list_editor.ex delete mode 100644 lib/modules/newsletters/web/list_editor.html.heex delete mode 100644 lib/modules/newsletters/web/list_members.ex delete mode 100644 lib/modules/newsletters/web/list_members.html.heex delete mode 100644 lib/modules/newsletters/web/lists.ex delete mode 100644 lib/modules/newsletters/web/lists.html.heex delete mode 100644 lib/modules/newsletters/web/unsubscribe_controller.ex delete mode 100644 lib/modules/newsletters/web/unsubscribe_html.ex delete mode 100644 lib/modules/newsletters/web/unsubscribe_html/unsubscribe.html.heex delete mode 100644 lib/modules/newsletters/workers/delivery_worker.ex delete mode 100644 lib/phoenix_kit_web/routes/newsletters.ex diff --git a/lib/modules/emails/sqs_processor.ex b/lib/modules/emails/sqs_processor.ex index 95f435101..d9a4f12db 100644 --- a/lib/modules/emails/sqs_processor.ex +++ b/lib/modules/emails/sqs_processor.ex @@ -45,8 +45,6 @@ defmodule PhoenixKit.Modules.Emails.SQSProcessor do alias PhoenixKit.Modules.Emails alias PhoenixKit.Modules.Emails.Event alias PhoenixKit.Modules.Emails.Log - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Modules.Newsletters.Broadcast, as: NewslettersBroadcast alias PhoenixKit.Utils.Date, as: UtilsDate import Ecto.Query @@ -1396,15 +1394,21 @@ defmodule PhoenixKit.Modules.Emails.SQSProcessor do end end - # Updates newsletters delivery record when a matching SES event arrives + # Updates newsletters delivery record when a matching SES event arrives. + # Uses ModuleRegistry lookup so it works when Newsletters is an external package. defp maybe_update_newsletters_delivery(message_id, event_type, timestamp) do - case Newsletters.find_delivery_by_message_id(message_id) do - nil -> :ok - delivery -> apply_delivery_event(delivery, event_type, timestamp) + newsletters_mod = PhoenixKit.ModuleRegistry.get_by_key("newsletters") + + if newsletters_mod && Code.ensure_loaded?(newsletters_mod) && + function_exported?(newsletters_mod, :find_delivery_by_message_id, 1) do + case newsletters_mod.find_delivery_by_message_id(message_id) do + nil -> :ok + delivery -> apply_delivery_event(newsletters_mod, delivery, event_type, timestamp) + end end end - defp apply_delivery_event(delivery, event_type, timestamp) do + defp apply_delivery_event(newsletters_mod, delivery, event_type, timestamp) do {status, attrs} = case event_type do "Delivery" -> {"delivered", %{delivered_at: timestamp}} @@ -1414,12 +1418,12 @@ defmodule PhoenixKit.Modules.Emails.SQSProcessor do end if status do - Newsletters.update_delivery_status(delivery, status, attrs) - increment_broadcast_counter(delivery.broadcast_uuid, event_type) + newsletters_mod.update_delivery_status(delivery, status, attrs) + increment_broadcast_counter(newsletters_mod, delivery.broadcast_uuid, event_type) end end - defp increment_broadcast_counter(broadcast_uuid, event_type) do + defp increment_broadcast_counter(newsletters_mod, broadcast_uuid, event_type) do field_name = case event_type do "Delivery" -> :delivered_count @@ -1429,9 +1433,13 @@ defmodule PhoenixKit.Modules.Emails.SQSProcessor do end if field_name do - NewslettersBroadcast - |> where([b], b.uuid == ^broadcast_uuid) - |> PhoenixKit.RepoHelper.repo().update_all(inc: [{field_name, 1}]) + broadcast_mod = newsletters_mod |> Module.concat("Broadcast") + + if Code.ensure_loaded?(broadcast_mod) do + broadcast_mod + |> where([b], b.uuid == ^broadcast_uuid) + |> PhoenixKit.RepoHelper.repo().update_all(inc: [{field_name, 1}]) + end end end diff --git a/lib/modules/newsletters/broadcast.ex b/lib/modules/newsletters/broadcast.ex deleted file mode 100644 index 33e0184fa..000000000 --- a/lib/modules/newsletters/broadcast.ex +++ /dev/null @@ -1,80 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Broadcast do - @moduledoc """ - Ecto schema for newsletter broadcasts. - """ - - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - @valid_statuses ["draft", "scheduled", "sending", "sent", "cancelled"] - - schema "phoenix_kit_newsletters_broadcasts" do - field :subject, :string - field :markdown_body, :string - field :html_body, :string - field :text_body, :string - field :status, :string, default: "draft" - field :scheduled_at, :utc_datetime - field :sent_at, :utc_datetime - field :total_recipients, :integer, default: 0 - field :sent_count, :integer, default: 0 - field :delivered_count, :integer, default: 0 - field :opened_count, :integer, default: 0 - field :bounced_count, :integer, default: 0 - field :template_uuid, UUIDv7 - field :list_uuid, UUIDv7 - field :created_by_user_uuid, UUIDv7 - - belongs_to :list, PhoenixKit.Modules.Newsletters.List, - foreign_key: :list_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - belongs_to :template, PhoenixKit.Modules.Emails.Template, - foreign_key: :template_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - belongs_to :created_by, PhoenixKit.Users.Auth.User, - foreign_key: :created_by_user_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - has_many :deliveries, PhoenixKit.Modules.Newsletters.Delivery, - foreign_key: :broadcast_uuid, - references: :uuid - - timestamps(type: :utc_datetime) - end - - def changeset(broadcast, attrs) do - broadcast - |> cast(attrs, [ - :subject, - :markdown_body, - :html_body, - :text_body, - :status, - :scheduled_at, - :sent_at, - :total_recipients, - :sent_count, - :delivered_count, - :opened_count, - :bounced_count, - :template_uuid, - :list_uuid, - :created_by_user_uuid - ]) - |> validate_required([:subject, :list_uuid]) - |> validate_length(:subject, min: 1, max: 998) - |> validate_inclusion(:status, @valid_statuses) - end - - def valid_statuses, do: @valid_statuses -end diff --git a/lib/modules/newsletters/broadcaster.ex b/lib/modules/newsletters/broadcaster.ex deleted file mode 100644 index 8da415d1a..000000000 --- a/lib/modules/newsletters/broadcaster.ex +++ /dev/null @@ -1,115 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Broadcaster do - @moduledoc """ - Orchestrates broadcast sending: paginates list members, creates Delivery - records and Oban jobs in batches. - """ - - require Logger - - import Ecto.Query - - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Modules.Newsletters.{Broadcast, Delivery, ListMember} - alias PhoenixKit.Modules.Newsletters.Workers.DeliveryWorker - alias PhoenixKit.Utils.Date, as: UtilsDate - - @batch_size 500 - - @doc """ - Starts sending a broadcast. Transitions status to `sending`, - creates delivery records, and enqueues Oban jobs. - """ - def send(%Broadcast{status: "draft"} = broadcast) do - do_send(broadcast) - end - - def send(%Broadcast{status: "scheduled"} = broadcast) do - do_send(broadcast) - end - - def send(%Broadcast{status: status}) do - {:error, {:invalid_status, status}} - end - - defp do_send(broadcast) do - repo = repo() - - # Render markdown to HTML before sending - html = - case Earmark.as_html(broadcast.markdown_body || "") do - {:ok, html, _warnings} -> html - {:error, html, _errors} -> html - end - - text = strip_html(html) - - {:ok, broadcast} = - Newsletters.update_broadcast(broadcast, %{ - status: "sending", - html_body: html, - text_body: text, - sent_at: UtilsDate.utc_now() - }) - - # Count total active members - total = Newsletters.count_active_members(broadcast.list_uuid) - {:ok, broadcast} = Newsletters.update_broadcast(broadcast, %{total_recipients: total}) - - # Process in batches using transaction-wrapped stream - repo.transaction(fn -> - stream_active_members(broadcast.list_uuid) - |> Stream.chunk_every(@batch_size) - |> Enum.each(fn batch -> - process_batch(broadcast, batch, repo) - end) - end) - - Logger.info("Broadcaster: Enqueued #{total} deliveries for broadcast #{broadcast.uuid}") - {:ok, broadcast} - end - - defp stream_active_members(list_uuid) do - ListMember - |> where([m], m.list_uuid == ^list_uuid and m.status == "active") - |> select([m], m.user_uuid) - |> repo().stream() - end - - defp process_batch(broadcast, user_uuids, repo) do - now = UtilsDate.utc_now() - - deliveries = - Enum.map(user_uuids, fn user_uuid -> - %{ - uuid: UUIDv7.generate(), - broadcast_uuid: broadcast.uuid, - user_uuid: user_uuid, - status: "pending", - inserted_at: now, - updated_at: now - } - end) - - {_count, inserted} = repo.insert_all(Delivery, deliveries, returning: [:uuid]) - - jobs = - Enum.map(inserted, fn %{uuid: delivery_uuid} -> - DeliveryWorker.new(%{ - delivery_uuid: delivery_uuid, - broadcast_uuid: broadcast.uuid - }) - end) - - Oban.insert_all(jobs) - end - - defp strip_html(html) do - html - |> String.replace(~r//, "\n") - |> String.replace(~r/<\/p>/, "\n\n") - |> String.replace(~r/<[^>]+>/, "") - |> String.trim() - end - - defp repo, do: PhoenixKit.RepoHelper.repo() -end diff --git a/lib/modules/newsletters/delivery.ex b/lib/modules/newsletters/delivery.ex deleted file mode 100644 index 647388260..000000000 --- a/lib/modules/newsletters/delivery.ex +++ /dev/null @@ -1,56 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Delivery do - @moduledoc """ - Ecto schema for per-recipient delivery tracking. - """ - - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - @valid_statuses ["pending", "sent", "delivered", "opened", "bounced", "failed"] - - schema "phoenix_kit_newsletters_deliveries" do - field :status, :string, default: "pending" - field :sent_at, :utc_datetime - field :delivered_at, :utc_datetime - field :opened_at, :utc_datetime - field :error, :string - field :message_id, :string - field :broadcast_uuid, UUIDv7 - field :user_uuid, UUIDv7 - - belongs_to :broadcast, PhoenixKit.Modules.Newsletters.Broadcast, - foreign_key: :broadcast_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - belongs_to :user, PhoenixKit.Users.Auth.User, - foreign_key: :user_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - timestamps(type: :utc_datetime) - end - - def changeset(delivery, attrs) do - delivery - |> cast(attrs, [ - :broadcast_uuid, - :user_uuid, - :status, - :sent_at, - :delivered_at, - :opened_at, - :error, - :message_id - ]) - |> validate_required([:broadcast_uuid, :user_uuid]) - |> validate_inclusion(:status, @valid_statuses) - |> unique_constraint(:message_id) - end - - def valid_statuses, do: @valid_statuses -end diff --git a/lib/modules/newsletters/list.ex b/lib/modules/newsletters/list.ex deleted file mode 100644 index bfee948af..000000000 --- a/lib/modules/newsletters/list.ex +++ /dev/null @@ -1,65 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.List do - @moduledoc """ - Ecto schema for newsletter lists. - """ - - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - @valid_statuses ["active", "archived"] - - schema "phoenix_kit_newsletters_lists" do - field :name, :string - field :slug, :string - field :description, :string - field :status, :string, default: "active" - field :is_default, :boolean, default: false - field :subscriber_count, :integer, default: 0 - - has_many :members, PhoenixKit.Modules.Newsletters.ListMember, - foreign_key: :list_uuid, - references: :uuid - - has_many :broadcasts, PhoenixKit.Modules.Newsletters.Broadcast, - foreign_key: :list_uuid, - references: :uuid - - timestamps(type: :utc_datetime) - end - - def changeset(list, attrs) do - list - |> cast(attrs, [:name, :slug, :description, :status, :is_default]) - |> validate_required([:name, :slug]) - |> validate_length(:name, min: 1, max: 255) - |> validate_length(:slug, min: 1, max: 255) - |> validate_inclusion(:status, @valid_statuses) - |> validate_format(:slug, ~r/^[a-z0-9][a-z0-9-]*$/, - message: "must contain only lowercase letters, numbers, and hyphens" - ) - |> unique_constraint(:slug) - |> auto_generate_slug() - end - - defp auto_generate_slug(changeset) do - case get_change(changeset, :slug) do - nil -> - case get_change(changeset, :name) do - nil -> changeset - name -> put_change(changeset, :slug, slugify(name)) - end - - _ -> - changeset - end - end - - defp slugify(name) do - name - |> String.downcase() - |> String.replace(~r/[^a-z0-9]+/, "-") - |> String.trim("-") - end -end diff --git a/lib/modules/newsletters/list_member.ex b/lib/modules/newsletters/list_member.ex deleted file mode 100644 index 5b0fd6538..000000000 --- a/lib/modules/newsletters/list_member.ex +++ /dev/null @@ -1,50 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.ListMember do - @moduledoc """ - Ecto schema for newsletter list membership. - """ - - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - @valid_statuses ["active", "unsubscribed"] - - schema "phoenix_kit_newsletters_list_members" do - field :status, :string, default: "active" - field :subscribed_at, :utc_datetime - field :unsubscribed_at, :utc_datetime - field :user_uuid, UUIDv7 - field :list_uuid, UUIDv7 - - belongs_to :user, PhoenixKit.Users.Auth.User, - foreign_key: :user_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - belongs_to :list, PhoenixKit.Modules.Newsletters.List, - foreign_key: :list_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - # No timestamps — uses subscribed_at/unsubscribed_at instead - end - - def changeset(member, attrs) do - member - |> cast(attrs, [:user_uuid, :list_uuid, :status, :subscribed_at, :unsubscribed_at]) - |> validate_required([:user_uuid, :list_uuid]) - |> validate_inclusion(:status, @valid_statuses) - |> unique_constraint([:user_uuid, :list_uuid]) - |> maybe_set_subscribed_at() - end - - defp maybe_set_subscribed_at(changeset) do - case get_field(changeset, :subscribed_at) do - nil -> put_change(changeset, :subscribed_at, PhoenixKit.Utils.Date.utc_now()) - _ -> changeset - end - end -end diff --git a/lib/modules/newsletters/newsletters.ex b/lib/modules/newsletters/newsletters.ex deleted file mode 100644 index cf1bac9aa..000000000 --- a/lib/modules/newsletters/newsletters.ex +++ /dev/null @@ -1,367 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters do - @moduledoc """ - Newsletters module — email broadcasts and subscription management. - - Provides newsletter list management, broadcast creation with Markdown editor, - per-recipient delivery tracking via Oban workers, and unsubscribe flow. - """ - - use PhoenixKit.Module - - require Logger - - alias PhoenixKit.Dashboard.Tab - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - - # ============================================================================ - # Module Behaviour Callbacks - # ============================================================================ - - @impl PhoenixKit.Module - def module_key, do: "newsletters" - - @impl PhoenixKit.Module - def module_name, do: "Newsletters" - - @impl PhoenixKit.Module - def enabled? do - Settings.get_boolean_setting("newsletters_enabled", false) - end - - @impl PhoenixKit.Module - def enable_system do - # Require emails module to be enabled - if Emails.enabled?() do - Settings.update_boolean_setting_with_module("newsletters_enabled", true, "newsletters") - else - {:error, :emails_required} - end - end - - @impl PhoenixKit.Module - def disable_system do - Settings.update_boolean_setting_with_module("newsletters_enabled", false, "newsletters") - end - - @impl PhoenixKit.Module - def permission_metadata do - %{ - key: "newsletters", - label: "Newsletters", - icon: "hero-megaphone", - description: "Email broadcasts and subscription management" - } - end - - @impl PhoenixKit.Module - def admin_tabs do - [ - Tab.new!( - id: :admin_newsletters, - label: "Newsletters", - icon: "hero-megaphone", - path: "newsletters/broadcasts", - priority: 520, - level: :admin, - permission: "newsletters", - match: :prefix, - group: :admin_modules, - subtab_display: :when_active, - highlight_with_subtabs: false, - subtab_indent: "pl-4" - ), - Tab.new!( - id: :admin_newsletters_broadcasts, - label: "Broadcasts", - icon: "hero-paper-airplane", - path: "newsletters/broadcasts", - priority: 521, - level: :admin, - permission: "newsletters", - parent: :admin_newsletters, - match: :prefix - ), - Tab.new!( - id: :admin_newsletters_lists, - label: "Lists", - icon: "hero-list-bullet", - path: "newsletters/lists", - priority: 522, - level: :admin, - permission: "newsletters", - parent: :admin_newsletters, - match: :prefix - ) - ] - end - - @impl PhoenixKit.Module - def route_module, do: PhoenixKitWeb.Routes.NewslettersRoutes - - # ============================================================================ - # Lists - # ============================================================================ - - alias PhoenixKit.Modules.Newsletters.{Broadcast, Broadcaster, Delivery, List, ListMember} - - import Ecto.Query - - def list_lists(filters \\ %{}) do - List - |> maybe_filter_status(filters) - |> order_by([l], asc: l.name) - |> repo().all() - end - - def get_list!(uuid), do: repo().get!(List, uuid) - - def get_list(uuid), do: repo().get(List, uuid) - - def create_list(attrs) do - %List{} - |> List.changeset(attrs) - |> repo().insert() - end - - def update_list(%List{} = list, attrs) do - list - |> List.changeset(attrs) - |> repo().update() - end - - def delete_list(%List{} = list), do: repo().delete(list) - - # ============================================================================ - # List Members - # ============================================================================ - - def list_members(list_uuid, filters \\ %{}) do - ListMember - |> where([m], m.list_uuid == ^list_uuid) - |> maybe_filter_member_status(filters) - |> preload(:user) - |> order_by([m], desc: m.subscribed_at) - |> apply_pagination(filters) - |> repo().all() - end - - def count_active_members(list_uuid) do - ListMember - |> where([m], m.list_uuid == ^list_uuid and m.status == "active") - |> repo().aggregate(:count) - end - - def subscribe_user(list_uuid, user_uuid) do - %ListMember{} - |> ListMember.changeset(%{list_uuid: list_uuid, user_uuid: user_uuid, status: "active"}) - |> repo().insert( - on_conflict: {:replace, [:status, :subscribed_at]}, - conflict_target: [:user_uuid, :list_uuid] - ) - |> case do - {:ok, member} -> - update_subscriber_count(list_uuid) - {:ok, member} - - error -> - error - end - end - - def unsubscribe_user(list_uuid, user_uuid) do - ListMember - |> where([m], m.list_uuid == ^list_uuid and m.user_uuid == ^user_uuid) - |> repo().one() - |> case do - nil -> - {:error, :not_found} - - member -> - member - |> ListMember.changeset(%{ - status: "unsubscribed", - unsubscribed_at: PhoenixKit.Utils.Date.utc_now() - }) - |> repo().update() - |> case do - {:ok, member} -> - update_subscriber_count(list_uuid) - {:ok, member} - - error -> - error - end - end - end - - def list_user_subscriptions(user_uuid) do - ListMember - |> where([m], m.user_uuid == ^user_uuid and m.status == "active") - |> preload(:list) - |> repo().all() - end - - def unsubscribe_from_all(user_uuid) do - ListMember - |> where([m], m.user_uuid == ^user_uuid and m.status == "active") - |> repo().update_all( - set: [status: "unsubscribed", unsubscribed_at: PhoenixKit.Utils.Date.utc_now()] - ) - end - - # ============================================================================ - # Broadcasts - # ============================================================================ - - def list_broadcasts(filters \\ %{}) do - Broadcast - |> maybe_filter_broadcast_status(filters) - |> preload([:list]) - |> order_by([b], desc: b.inserted_at) - |> apply_pagination(filters) - |> repo().all() - end - - def get_broadcast!(uuid) do - Broadcast - |> preload([:list, :template]) - |> repo().get!(uuid) - end - - def create_broadcast(attrs) do - %Broadcast{} - |> Broadcast.changeset(attrs) - |> repo().insert() - end - - def update_broadcast(%Broadcast{} = broadcast, attrs) do - broadcast - |> Broadcast.changeset(attrs) - |> repo().update() - end - - def delete_broadcast(%Broadcast{status: "draft"} = broadcast), do: repo().delete(broadcast) - def delete_broadcast(_), do: {:error, :cannot_delete_non_draft} - - def render_broadcast_html(%Broadcast{} = broadcast) do - case Earmark.as_html(broadcast.markdown_body || "") do - {:ok, html, _} -> {:ok, html} - {:error, _, errors} -> {:error, errors} - end - end - - # ============================================================================ - # Deliveries - # ============================================================================ - - def list_deliveries(broadcast_uuid, filters \\ %{}) do - Delivery - |> where([d], d.broadcast_uuid == ^broadcast_uuid) - |> maybe_filter_delivery_status(filters) - |> preload(:user) - |> order_by([d], desc: d.inserted_at) - |> apply_pagination(filters) - |> repo().all() - end - - def get_delivery_stats(broadcast_uuid) do - Delivery - |> where([d], d.broadcast_uuid == ^broadcast_uuid) - |> group_by([d], d.status) - |> select([d], {d.status, count(d.uuid)}) - |> repo().all() - |> Map.new() - end - - def update_delivery_status(%Delivery{} = delivery, status, attrs \\ %{}) do - delivery - |> Delivery.changeset(Map.merge(attrs, %{status: status})) - |> repo().update() - end - - def find_delivery_by_message_id(message_id) do - Delivery - |> where([d], d.message_id == ^message_id) - |> preload(:broadcast) - |> repo().one() - end - - # ============================================================================ - # Scheduled Processing - # ============================================================================ - - def process_scheduled_broadcasts do - now = PhoenixKit.Utils.Date.utc_now() - - broadcasts = - Broadcast - |> where([b], b.status == "scheduled" and b.scheduled_at <= ^now) - |> order_by([b], asc: b.scheduled_at) - |> repo().all() - - count = - Enum.reduce(broadcasts, 0, fn broadcast, acc -> - case Broadcaster.send(broadcast) do - {:ok, _} -> - acc + 1 - - {:error, reason} -> - Logger.warning( - "Failed to send scheduled broadcast #{broadcast.uuid}: #{inspect(reason)}" - ) - - acc - end - end) - - {:ok, count} - end - - # ============================================================================ - # Private Helpers - # ============================================================================ - - defp repo, do: PhoenixKit.RepoHelper.repo() - - defp update_subscriber_count(list_uuid) do - count = count_active_members(list_uuid) - - List - |> where([l], l.uuid == ^list_uuid) - |> repo().update_all(set: [subscriber_count: count]) - end - - defp maybe_filter_status(query, %{status: status}) when is_binary(status) and status != "" do - where(query, [l], l.status == ^status) - end - - defp maybe_filter_status(query, _), do: query - - defp maybe_filter_member_status(query, %{status: status}) - when is_binary(status) and status != "" do - where(query, [m], m.status == ^status) - end - - defp maybe_filter_member_status(query, _), do: query - - defp maybe_filter_broadcast_status(query, %{status: status}) - when is_binary(status) and status != "" do - where(query, [b], b.status == ^status) - end - - defp maybe_filter_broadcast_status(query, _), do: query - - defp maybe_filter_delivery_status(query, %{status: status}) - when is_binary(status) and status != "" do - where(query, [d], d.status == ^status) - end - - defp maybe_filter_delivery_status(query, _), do: query - - defp apply_pagination(query, filters) do - limit = Map.get(filters, :limit, 50) - offset = Map.get(filters, :offset, 0) - query |> limit(^limit) |> offset(^offset) - end -end diff --git a/lib/modules/newsletters/web/broadcast_details.ex b/lib/modules/newsletters/web/broadcast_details.ex deleted file mode 100644 index 4d03bfe26..000000000 --- a/lib/modules/newsletters/web/broadcast_details.ex +++ /dev/null @@ -1,147 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Web.BroadcastDetails do - @moduledoc """ - LiveView for viewing broadcast details and delivery statistics. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => id}, _session, socket) do - if Newsletters.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:broadcast_id, id) - |> assign(:project_title, project_title) - |> assign(:broadcast, nil) - |> assign(:deliveries, []) - |> assign(:delivery_stats, %{}) - |> assign(:loading, true) - |> assign(:show_confirm_modal, false) - |> assign(:confirm_action, nil) - |> assign(:confirm_target, nil) - |> assign(:confirm_title, "") - |> assign(:confirm_message, "") - |> load_broadcast_data() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Newsletters module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_event("show_confirm", %{"action" => "cancel_broadcast"}, socket) do - {:noreply, - socket - |> assign(:show_confirm_modal, true) - |> assign(:confirm_action, :cancel_broadcast) - |> assign(:confirm_title, "Cancel Broadcast") - |> assign(:confirm_message, "This will stop any remaining deliveries for this broadcast.")} - end - - @impl true - def handle_event("hide_confirm", _params, socket) do - {:noreply, - socket - |> assign(:show_confirm_modal, false) - |> assign(:confirm_action, nil)} - end - - @impl true - def handle_event("confirm_action", _params, socket) do - socket = assign(socket, :show_confirm_modal, false) - - case socket.assigns.confirm_action do - :cancel_broadcast -> - case Newsletters.update_broadcast(socket.assigns.broadcast, %{status: "cancelled"}) do - {:ok, broadcast} -> - {:noreply, - socket - |> assign(:broadcast, broadcast) - |> put_flash(:info, "Broadcast cancelled")} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, "Failed to cancel broadcast")} - end - - _ -> - {:noreply, socket} - end - end - - @impl true - def handle_event("refresh", _params, socket) do - {:noreply, - socket - |> assign(:loading, true) - |> load_broadcast_data()} - end - - # --- Private --- - - defp load_broadcast_data(socket) do - id = socket.assigns.broadcast_id - - try do - broadcast = Newsletters.get_broadcast!(id) - deliveries = Newsletters.list_deliveries(id) - stats = Newsletters.get_delivery_stats(id) - - socket - |> assign(:broadcast, broadcast) - |> assign(:deliveries, deliveries) - |> assign(:delivery_stats, stats) - |> assign(:loading, false) - |> assign(:page_title, broadcast.subject) - |> assign(:url_path, Routes.path("/admin/newsletters/broadcasts/#{id}")) - rescue - Ecto.NoResultsError -> - socket - |> assign(:loading, false) - |> put_flash(:error, "Broadcast not found") - |> push_navigate(to: Routes.path("/admin/newsletters/broadcasts")) - end - end - - defp status_badge_class(status) do - case status do - "draft" -> "badge-ghost" - "scheduled" -> "badge-info" - "sending" -> "badge-warning" - "sent" -> "badge-success" - "cancelled" -> "badge-error" - _ -> "badge-ghost" - end - end - - defp delivery_badge_class(status) do - case status do - "pending" -> "badge-ghost" - "sent" -> "badge-info" - "delivered" -> "badge-success" - "opened" -> "badge-primary" - "bounced" -> "badge-warning" - "failed" -> "badge-error" - _ -> "badge-ghost" - end - end - - defp format_datetime(nil), do: "-" - - defp format_datetime(dt) do - Calendar.strftime(dt, "%Y-%m-%d %H:%M") - end - - defp stat_value(stats, key) do - Map.get(stats, key, 0) - end -end diff --git a/lib/modules/newsletters/web/broadcast_details.html.heex b/lib/modules/newsletters/web/broadcast_details.html.heex deleted file mode 100644 index 48f7f9674..000000000 --- a/lib/modules/newsletters/web/broadcast_details.html.heex +++ /dev/null @@ -1,223 +0,0 @@ - -
- <.admin_page_header back={Routes.path("/admin/newsletters/broadcasts")}> -

- {if @broadcast, do: @broadcast.subject, else: "Broadcast Details"} -

- - - <%!-- Confirm Modal --%> - <%= if @show_confirm_modal do %> - - <% end %> - - <%= if @loading do %> -
- - Loading... -
- <% else %> - <%= if @broadcast do %> - <%!-- Header actions --%> -
- <%= if @broadcast.status == "draft" do %> - <.pk_link - navigate={"/admin/newsletters/broadcasts/#{@broadcast.uuid}/edit"} - class="btn btn-outline btn-sm" - > - <.icon name="hero-pencil" class="w-4 h-4" /> Edit - - <% end %> - - <%= if @broadcast.status == "sending" do %> - - <% end %> - - -
- - <%!-- Metadata --%> -
-
-
-
-

Status

- - {@broadcast.status} - -
-
-

List

-

- {if @broadcast.list, do: @broadcast.list.name, else: "-"} -

-
-
-

Template

-

- {if @broadcast.template, - do: - PhoenixKit.Modules.Emails.Template.get_translation( - @broadcast.template.display_name, - "en" - ) || @broadcast.template.name, - else: "-"} -

-
-
-

Sent At

-

{format_datetime(@broadcast.sent_at)}

-
-
-
-
- - <%!-- Delivery Stats --%> -
-
-
Total Recipients
-
{@broadcast.total_recipients}
-
-
-
Sent
-
{stat_value(@delivery_stats, "sent")}
-
-
-
Delivered
-
- {stat_value(@delivery_stats, "delivered")} -
-
-
-
Opened
-
- {stat_value(@delivery_stats, "opened")} -
-
-
-
Bounced
-
- {stat_value(@delivery_stats, "bounced")} -
-
-
-
Failed
-
- {stat_value(@delivery_stats, "failed")} -
-
-
- - <%!-- Deliveries Table (desktop) --%> -

Deliveries

- <%= if @deliveries == [] do %> -
-

No deliveries yet

-
- <% else %> - - - <%!-- Deliveries Cards (mobile) --%> -
-
- <%= for delivery <- @deliveries do %> -
-
- <%!-- Header: email + status --%> -
- - {if delivery.user, do: delivery.user.email, else: delivery.user_uuid} - - - {delivery.status} - -
- - <%!-- Body: key/value grid --%> -
-
Sent At
-
{format_datetime(delivery.sent_at)}
- -
Delivered At
-
{format_datetime(delivery.delivered_at)}
- -
Opened At
-
{format_datetime(delivery.opened_at)}
- - <%= if delivery.error do %> -
Error
-
{delivery.error}
- <% end %> -
-
-
- <% end %> -
-
- <% end %> - <% end %> - <% end %> -
-
diff --git a/lib/modules/newsletters/web/broadcast_editor.ex b/lib/modules/newsletters/web/broadcast_editor.ex deleted file mode 100644 index 613290ee1..000000000 --- a/lib/modules/newsletters/web/broadcast_editor.ex +++ /dev/null @@ -1,259 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Web.BroadcastEditor do - @moduledoc """ - LiveView for creating and editing newsletter broadcasts with Markdown editor and live preview. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Emails.Template - alias PhoenixKit.Modules.Emails.Templates - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Modules.Newsletters.Broadcaster - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Newsletters.enabled?() do - project_title = Settings.get_project_title() - lists = Newsletters.list_lists(%{status: "active"}) - templates = Templates.list_templates(%{status: "active"}) - - default_template_uuid = Settings.get_setting("newsletters_default_template") - - socket = - socket - |> assign(:page_title, "New Broadcast") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/newsletters/broadcasts/new")) - |> assign(:lists, lists) - |> assign(:templates, templates) - |> assign(:broadcast, nil) - |> assign(:subject, "") - |> assign(:list_uuid, "") - |> assign(:template_uuid, default_template_uuid || "") - |> assign(:markdown_content, "") - |> assign(:preview_html, "") - |> assign(:scheduled_at, "") - |> assign(:saving, false) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Newsletters module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(%{"id" => id}, _url, %{assigns: %{live_action: :edit}} = socket) do - broadcast = Newsletters.get_broadcast!(id) - - {:noreply, - socket - |> assign(:page_title, "Edit Broadcast") - |> assign(:url_path, Routes.path("/admin/newsletters/broadcasts/#{id}/edit")) - |> assign(:broadcast, broadcast) - |> assign(:subject, broadcast.subject || "") - |> assign(:list_uuid, broadcast.list_uuid || "") - |> assign(:template_uuid, broadcast.template_uuid || "") - |> assign(:markdown_content, broadcast.markdown_body || "") - |> assign( - :preview_html, - render_preview(broadcast.markdown_body, broadcast.template_uuid, socket.assigns.templates) - )} - rescue - Ecto.NoResultsError -> - {:noreply, - socket - |> put_flash(:error, "Broadcast not found") - |> push_navigate(to: Routes.path("/admin/newsletters/broadcasts"))} - end - - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("validate", params, socket) do - subject = params["subject"] || socket.assigns.subject - list_uuid = params["list_uuid"] || socket.assigns.list_uuid - template_uuid = params["template_uuid"] || socket.assigns.template_uuid - scheduled_at = params["scheduled_at"] || socket.assigns.scheduled_at - - preview_html = - render_preview(socket.assigns.markdown_content, template_uuid, socket.assigns.templates) - - {:noreply, - socket - |> assign(:subject, subject) - |> assign(:list_uuid, list_uuid) - |> assign(:template_uuid, template_uuid) - |> assign(:scheduled_at, scheduled_at) - |> assign(:preview_html, preview_html)} - end - - @impl true - def handle_event("save_draft", params, socket) do - socket = update_assigns_from_params(socket, params) - save_broadcast(socket, "draft") - end - - @impl true - def handle_event("send_now", params, socket) do - socket = update_assigns_from_params(socket, params) - - case save_broadcast_and_return(socket) do - {:ok, broadcast} -> - case Broadcaster.send(broadcast) do - {:ok, _broadcast} -> - {:noreply, - socket - |> put_flash(:info, "Broadcast is being sent") - |> push_navigate(to: Routes.path("/admin/newsletters/broadcasts/#{broadcast.uuid}"))} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to send: #{inspect(reason)}")} - end - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to save: #{inspect(reason)}")} - end - end - - @impl true - def handle_event("schedule", params, socket) do - socket = update_assigns_from_params(socket, params) - - case socket.assigns.scheduled_at do - "" -> - {:noreply, put_flash(socket, :error, "Please select a schedule date and time")} - - scheduled_at_str -> - save_broadcast(socket, "scheduled", %{scheduled_at: parse_datetime(scheduled_at_str)}) - end - end - - @impl true - def handle_info({:editor_content_changed, %{content: content}}, socket) do - preview_html = render_preview(content, socket.assigns.template_uuid, socket.assigns.templates) - - {:noreply, - socket - |> assign(:markdown_content, content) - |> assign(:preview_html, preview_html)} - end - - @impl true - def handle_info(_msg, socket), do: {:noreply, socket} - - # --- Private --- - - defp update_assigns_from_params(socket, params) do - socket - |> assign(:subject, params["subject"] || socket.assigns.subject) - |> assign(:list_uuid, params["list_uuid"] || socket.assigns.list_uuid) - |> assign(:template_uuid, params["template_uuid"] || socket.assigns.template_uuid) - |> assign(:scheduled_at, params["scheduled_at"] || socket.assigns.scheduled_at) - end - - defp save_broadcast(socket, status, extra_attrs \\ %{}) do - socket = assign(socket, :saving, true) - - attrs = - Map.merge( - %{ - subject: socket.assigns.subject, - list_uuid: socket.assigns.list_uuid, - template_uuid: - if(socket.assigns.template_uuid == "", do: nil, else: socket.assigns.template_uuid), - markdown_body: socket.assigns.markdown_content, - status: status - }, - extra_attrs - ) - - result = - case socket.assigns.broadcast do - nil -> Newsletters.create_broadcast(attrs) - broadcast -> Newsletters.update_broadcast(broadcast, attrs) - end - - case result do - {:ok, broadcast} -> - {:noreply, - socket - |> assign(:saving, false) - |> assign(:broadcast, broadcast) - |> put_flash(:info, "Broadcast saved as #{status}") - |> push_navigate(to: Routes.path("/admin/newsletters/broadcasts"))} - - {:error, changeset} -> - errors = - changeset - |> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end) - |> Enum.map_join(", ", fn {field, msgs} -> "#{field}: #{Enum.join(msgs, ", ")}" end) - - {:noreply, - socket - |> assign(:saving, false) - |> put_flash(:error, "Validation failed: #{errors}")} - end - end - - defp save_broadcast_and_return(socket) do - attrs = %{ - subject: socket.assigns.subject, - list_uuid: socket.assigns.list_uuid, - template_uuid: - if(socket.assigns.template_uuid == "", do: nil, else: socket.assigns.template_uuid), - markdown_body: socket.assigns.markdown_content, - status: "draft" - } - - case socket.assigns.broadcast do - nil -> Newsletters.create_broadcast(attrs) - broadcast -> Newsletters.update_broadcast(broadcast, attrs) - end - end - - defp render_preview(markdown, template_uuid, templates) do - case Earmark.as_html(markdown || "") do - {:ok, html, _} -> - inject_into_template(html, template_uuid, templates) - - _ -> - "" - end - end - - defp inject_into_template(html, template_uuid, templates) - when is_binary(template_uuid) and template_uuid != "" do - case Enum.find(templates, fn t -> t.uuid == template_uuid end) do - nil -> - html - - template -> - html_template = Template.get_translation(template.html_body, "en") - String.replace(html_template, "{{content}}", html) - end - end - - defp inject_into_template(html, _, _), do: html - - defp parse_datetime(str) when is_binary(str) do - case DateTime.from_iso8601(str <> ":00Z") do - {:ok, dt, _} -> - dt - - _ -> - case NaiveDateTime.from_iso8601(str <> ":00") do - {:ok, ndt} -> DateTime.from_naive!(ndt, "Etc/UTC") - _ -> nil - end - end - end - - defp parse_datetime(_), do: nil -end diff --git a/lib/modules/newsletters/web/broadcast_editor.html.heex b/lib/modules/newsletters/web/broadcast_editor.html.heex deleted file mode 100644 index 930724953..000000000 --- a/lib/modules/newsletters/web/broadcast_editor.html.heex +++ /dev/null @@ -1,137 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin/newsletters/broadcasts")} - title={@page_title} - subtitle="Compose and send a broadcast email to your newsletter list" - /> - -
-
- <%!-- Left: Editor --%> -
- <%!-- Subject --%> -
- - -
- - <%!-- List selector --%> -
- - -
- - <%!-- Template selector --%> -
- - -
- - <%!-- Markdown Editor --%> -
- - <.live_component - module={PhoenixKitWeb.Components.Core.MarkdownEditor} - id="broadcast-editor" - content={@markdown_content} - save_status={:saved} - show_formatting_toolbar={true} - protect_navigation={false} - height="400px" - placeholder="Write your broadcast content in Markdown..." - /> -
- - <%!-- Actions --%> -
- - - - -
- - -
-
-
- - <%!-- Right: Preview --%> -
- -
- <%= if @preview_html != "" do %> - - <% else %> -
-
- <.icon name="hero-eye" class="w-10 h-10 mx-auto mb-2 opacity-40" /> -

Preview will appear here as you type

-
-
- <% end %> -
-
-
-
-
-
diff --git a/lib/modules/newsletters/web/broadcasts.ex b/lib/modules/newsletters/web/broadcasts.ex deleted file mode 100644 index 477968fbc..000000000 --- a/lib/modules/newsletters/web/broadcasts.ex +++ /dev/null @@ -1,80 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Web.Broadcasts do - @moduledoc """ - LiveView for the broadcasts list in the newsletters admin panel. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Newsletters.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Broadcasts") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/newsletters/broadcasts")) - |> assign(:broadcasts, []) - |> assign(:status_filter, "") - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Newsletters module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(params, _url, socket) do - status = params["status"] || "" - - broadcasts = Newsletters.list_broadcasts(%{status: status}) - - {:noreply, - socket - |> assign(:status_filter, status) - |> assign(:broadcasts, broadcasts)} - end - - @impl true - def handle_event("filter_status", %{"status" => status}, socket) do - params = if status == "", do: %{}, else: %{"status" => status} - query = URI.encode_query(params) - - path = - if query == "", - do: "/admin/newsletters/broadcasts", - else: "/admin/newsletters/broadcasts?#{query}" - - {:noreply, push_patch(socket, to: Routes.path(path))} - end - - @impl true - def handle_event("view_broadcast", %{"uuid" => uuid}, socket) do - {:noreply, push_navigate(socket, to: Routes.path("/admin/newsletters/broadcasts/#{uuid}"))} - end - - defp status_badge_class(status) do - case status do - "draft" -> "badge-ghost" - "scheduled" -> "badge-info" - "sending" -> "badge-warning" - "sent" -> "badge-success" - "cancelled" -> "badge-error" - _ -> "badge-ghost" - end - end - - defp format_datetime(nil), do: "-" - - defp format_datetime(dt) do - Calendar.strftime(dt, "%Y-%m-%d %H:%M") - end -end diff --git a/lib/modules/newsletters/web/broadcasts.html.heex b/lib/modules/newsletters/web/broadcasts.html.heex deleted file mode 100644 index 4072cc1e2..000000000 --- a/lib/modules/newsletters/web/broadcasts.html.heex +++ /dev/null @@ -1,149 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin")} - title="Broadcasts" - subtitle="Create and manage email broadcasts" - /> - - <%!-- Actions Bar --%> -
-
- -
- - <.pk_link navigate="/admin/newsletters/broadcasts/new" class="btn btn-primary btn-sm"> - <.icon name="hero-plus" class="w-4 h-4" /> New Broadcast - -
- - <%!-- Broadcasts Table (desktop) --%> - <%= if @broadcasts == [] do %> -
- <.icon name="hero-megaphone" class="w-12 h-12 mx-auto mb-4 opacity-40" /> -

No broadcasts found

-

Create your first broadcast to get started.

-
- <% else %> - - - <%!-- Broadcasts Cards (mobile) --%> -
-
- <%= for broadcast <- @broadcasts do %> -
-
- <%!-- Header: subject + status --%> -
- {broadcast.subject} - - {broadcast.status} - -
- - <%!-- Body: key/value grid --%> -
-
List
-
- {if broadcast.list, do: broadcast.list.name, else: "-"} -
- -
Sent
-
{broadcast.sent_count}
- -
Delivered
-
{broadcast.delivered_count}
- -
Opened
-
{broadcast.opened_count}
- -
Bounced
-
{broadcast.bounced_count}
- -
Sent At
-
{format_datetime(broadcast.sent_at)}
-
-
-
- <% end %> -
-
- <% end %> -
-
diff --git a/lib/modules/newsletters/web/list_editor.ex b/lib/modules/newsletters/web/list_editor.ex deleted file mode 100644 index 21b25d960..000000000 --- a/lib/modules/newsletters/web/list_editor.ex +++ /dev/null @@ -1,85 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Web.ListEditor do - @moduledoc """ - LiveView for creating and editing newsletter lists. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Modules.Newsletters.List - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Newsletters.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:list, nil) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Newsletters module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(%{"id" => id}, _url, socket) do - list = Newsletters.get_list!(id) - - {:noreply, - socket - |> assign(:page_title, "Edit List: #{list.name}") - |> assign(:url_path, Routes.path("/admin/newsletters/lists/#{id}/edit")) - |> assign(:list, list) - |> assign(:form, to_form(List.changeset(list, %{})))} - rescue - Ecto.NoResultsError -> - {:noreply, - socket - |> put_flash(:error, "List not found") - |> push_navigate(to: Routes.path("/admin/newsletters/lists"))} - end - - def handle_params(_params, _url, socket) do - {:noreply, - socket - |> assign(:page_title, "New List") - |> assign(:url_path, Routes.path("/admin/newsletters/lists/new")) - |> assign(:list, nil) - |> assign(:form, to_form(List.changeset(%List{}, %{})))} - end - - @impl true - def handle_event("validate", %{"list" => params}, socket) do - target = socket.assigns.list || %List{} - changeset = List.changeset(target, params) |> Map.put(:action, :validate) - {:noreply, assign(socket, :form, to_form(changeset))} - end - - @impl true - def handle_event("save", %{"list" => params}, socket) do - result = - case socket.assigns.list do - nil -> Newsletters.create_list(params) - list -> Newsletters.update_list(list, params) - end - - case result do - {:ok, _list} -> - {:noreply, - socket - |> put_flash(:info, "List saved successfully") - |> push_navigate(to: Routes.path("/admin/newsletters/lists"))} - - {:error, changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} - end - end -end diff --git a/lib/modules/newsletters/web/list_editor.html.heex b/lib/modules/newsletters/web/list_editor.html.heex deleted file mode 100644 index 850de0a45..000000000 --- a/lib/modules/newsletters/web/list_editor.html.heex +++ /dev/null @@ -1,105 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin/newsletters/lists")} - title={@page_title} - subtitle={ - if @list, do: "Edit newsletter list settings", else: "Create a new newsletter list" - } - /> - -
-
-
- <.form for={@form} phx-change="validate" phx-submit="save"> -
-
- - <.input - field={@form[:name]} - type="text" - placeholder="My Newsletter" - class="input w-full" - /> -
- -
- - <.input - field={@form[:slug]} - type="text" - placeholder="my-newsletter" - class="input w-full" - /> -

- Auto-generated from name if left empty -

-
- -
- - -
- -
- - -
- -
- -
-
- -
- <.pk_link navigate="/admin/newsletters/lists" class="btn btn-ghost"> - Cancel - - -
- -
-
-
-
-
diff --git a/lib/modules/newsletters/web/list_members.ex b/lib/modules/newsletters/web/list_members.ex deleted file mode 100644 index eccbab4b8..000000000 --- a/lib/modules/newsletters/web/list_members.ex +++ /dev/null @@ -1,205 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Web.ListMembers do - @moduledoc """ - LiveView for managing members of a newsletter list. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Settings - alias PhoenixKit.Users.Auth - alias PhoenixKit.Utils.Routes - - @impl true - def mount(%{"id" => list_uuid}, _session, socket) do - if Newsletters.enabled?() do - list = Newsletters.get_list!(list_uuid) - members = Newsletters.list_members(list_uuid) - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "#{list.name} — Members") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/newsletters/lists/#{list_uuid}/members")) - |> assign(:list, list) - |> assign(:members, members) - |> assign(:status_filter, "") - |> assign(:search_query, "") - |> assign(:search_results, []) - |> assign(:show_confirm_modal, false) - |> assign(:confirm_action, nil) - |> assign(:confirm_target, nil) - |> assign(:confirm_title, "") - |> assign(:confirm_message, "") - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Newsletters module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_event("show_confirm", %{"action" => action} = params, socket) do - {title, message} = confirm_text(action) - - {:noreply, - socket - |> assign(:show_confirm_modal, true) - |> assign(:confirm_action, String.to_existing_atom(action)) - |> assign(:confirm_target, params["uuid"]) - |> assign(:confirm_title, title) - |> assign(:confirm_message, message)} - end - - @impl true - def handle_event("hide_confirm", _params, socket) do - {:noreply, - socket - |> assign(:show_confirm_modal, false) - |> assign(:confirm_action, nil) - |> assign(:confirm_target, nil)} - end - - @impl true - def handle_event("confirm_action", _params, socket) do - socket = assign(socket, :show_confirm_modal, false) - - case socket.assigns.confirm_action do - :add_all_users -> handle_add_all_users(socket) - :unsubscribe -> handle_unsubscribe(socket, socket.assigns.confirm_target) - :remove -> handle_remove(socket, socket.assigns.confirm_target) - _ -> {:noreply, socket} - end - end - - @impl true - def handle_event("filter_status", %{"status" => status}, socket) do - members = Newsletters.list_members(socket.assigns.list.uuid, %{status: status}) - - {:noreply, - socket - |> assign(:members, members) - |> assign(:status_filter, status)} - end - - @impl true - def handle_event("search_users", %{"query" => query}, socket) do - results = - if String.length(String.trim(query)) >= 2 do - Auth.search_users(query) - else - [] - end - - {:noreply, - socket - |> assign(:search_query, query) - |> assign(:search_results, results)} - end - - @impl true - def handle_event("add_member", %{"user-uuid" => user_uuid}, socket) do - case Newsletters.subscribe_user(socket.assigns.list.uuid, user_uuid) do - {:ok, _} -> - members = reload_members(socket) - - {:noreply, - socket - |> put_flash(:info, "Member added") - |> assign(:members, members) - |> assign(:search_query, "") - |> assign(:search_results, [])} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Could not add member")} - end - end - - defp handle_add_all_users(socket) do - %{users: users} = Auth.list_users_paginated(page_size: 1000) - - added = - Enum.reduce(users, 0, fn user, acc -> - case Newsletters.subscribe_user(socket.assigns.list.uuid, user.uuid) do - {:ok, _} -> acc + 1 - _ -> acc - end - end) - - members = reload_members(socket) - - {:noreply, - socket - |> put_flash(:info, "Added #{added} users to the list") - |> assign(:members, members) - |> assign(:search_query, "") - |> assign(:search_results, [])} - end - - defp handle_unsubscribe(socket, member_uuid) do - member = find_member(socket.assigns.members, member_uuid) - - if member do - case Newsletters.unsubscribe_user(member.list_uuid, member.user_uuid) do - {:ok, _} -> - members = reload_members(socket) - - {:noreply, - socket - |> put_flash(:info, "Member unsubscribed") - |> assign(:members, members)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Could not unsubscribe member")} - end - else - {:noreply, put_flash(socket, :error, "Member not found")} - end - end - - defp handle_remove(socket, member_uuid) do - member = find_member(socket.assigns.members, member_uuid) - - if member do - repo = PhoenixKit.RepoHelper.repo() - - case repo.delete(member) do - {:ok, _} -> - members = reload_members(socket) - - {:noreply, - socket - |> put_flash(:info, "Member removed") - |> assign(:members, members)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Could not remove member")} - end - else - {:noreply, put_flash(socket, :error, "Member not found")} - end - end - - defp confirm_text("add_all_users"), - do: {"Add All Users", "All registered users will be subscribed to this list."} - - defp confirm_text("unsubscribe"), - do: {"Unsubscribe Member", "This member will be unsubscribed from the list."} - - defp confirm_text("remove"), - do: {"Remove Member", "This member will be permanently removed from the list."} - - defp confirm_text(_), do: {"Confirm", "Are you sure?"} - - defp find_member(members, uuid) do - Enum.find(members, fn m -> to_string(m.uuid) == uuid end) - end - - defp reload_members(socket) do - Newsletters.list_members(socket.assigns.list.uuid, %{status: socket.assigns.status_filter}) - end -end diff --git a/lib/modules/newsletters/web/list_members.html.heex b/lib/modules/newsletters/web/list_members.html.heex deleted file mode 100644 index 85f47c685..000000000 --- a/lib/modules/newsletters/web/list_members.html.heex +++ /dev/null @@ -1,247 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin/newsletters/lists")} - title={@list.name} - subtitle={"#{@list.subscriber_count} active subscribers"} - /> - - <%!-- Confirm Modal --%> - <%= if @show_confirm_modal do %> - - <% end %> - - <%!-- Add Members --%> -
-
-
-

Add Members

- -
- -
- -
- - <%= if length(@search_results) > 0 do %> -
- <%= for user <- @search_results do %> -
-
- {user.email} - <%= if user.username do %> - @{user.username} - <% end %> -
- -
- <% end %> -
- <% end %> - - <%= if @search_query != "" and length(@search_results) == 0 do %> -

- No users found matching "{@search_query}" -

- <% end %> -
-
- - <%!-- Filters --%> -
-
- Filter by status: - -
-
- - <%!-- Members Table (desktop) --%> - <%= if length(@members) > 0 do %> - - - <%!-- Members Cards (mobile) --%> -
-
- <%= for member <- @members do %> -
-
- <%!-- Header: email + status --%> -
- - <%= if member.user do %> - {member.user.email} - <% else %> - (unknown) - <% end %> - - <%= if member.status == "active" do %> - Active - <% else %> - Unsubscribed - <% end %> -
- - <%!-- Body: key/value grid --%> -
-
Subscribed At
-
- <%= if member.subscribed_at do %> - {Calendar.strftime(member.subscribed_at, "%Y-%m-%d %H:%M")} - <% else %> - - - <% end %> -
-
- - <%!-- Footer: actions --%> -
- <%= if member.status == "active" do %> - - <% end %> - -
-
-
- <% end %> -
-
- <% else %> -
- <.icon name="hero-users" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

No members found

-

- <%= if @status_filter != "" do %> - Try changing the filter above. - <% else %> - This list has no subscribers yet. - <% end %> -

-
- <% end %> -
-
diff --git a/lib/modules/newsletters/web/lists.ex b/lib/modules/newsletters/web/lists.ex deleted file mode 100644 index a67cd6f36..000000000 --- a/lib/modules/newsletters/web/lists.ex +++ /dev/null @@ -1,86 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Web.Lists do - @moduledoc """ - LiveView for managing newsletter lists. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - if Newsletters.enabled?() do - lists = Newsletters.list_lists() - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Newsletters Lists") - |> assign(:project_title, project_title) - |> assign(:url_path, Routes.path("/admin/newsletters/lists")) - |> assign(:lists, lists) - |> assign(:show_confirm_modal, false) - |> assign(:confirm_action, nil) - |> assign(:confirm_target, nil) - |> assign(:confirm_title, "") - |> assign(:confirm_message, "") - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Newsletters module is not enabled") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_event("show_confirm", %{"action" => "delete", "uuid" => uuid}, socket) do - {:noreply, - socket - |> assign(:show_confirm_modal, true) - |> assign(:confirm_action, :delete) - |> assign(:confirm_target, uuid) - |> assign(:confirm_title, "Delete List") - |> assign(:confirm_message, "This list and all its data will be permanently deleted.")} - end - - @impl true - def handle_event("hide_confirm", _params, socket) do - {:noreply, - socket - |> assign(:show_confirm_modal, false) - |> assign(:confirm_action, nil) - |> assign(:confirm_target, nil)} - end - - @impl true - def handle_event("confirm_action", _params, socket) do - socket = assign(socket, :show_confirm_modal, false) - - case socket.assigns.confirm_action do - :delete -> - handle_delete(socket, socket.assigns.confirm_target) - - _ -> - {:noreply, socket} - end - end - - defp handle_delete(socket, uuid) do - list = Newsletters.get_list!(uuid) - - case Newsletters.delete_list(list) do - {:ok, _} -> - {:noreply, - socket - |> put_flash(:info, "List deleted") - |> assign(:lists, Newsletters.list_lists())} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Cannot delete list")} - end - end -end diff --git a/lib/modules/newsletters/web/lists.html.heex b/lib/modules/newsletters/web/lists.html.heex deleted file mode 100644 index 01cb134c1..000000000 --- a/lib/modules/newsletters/web/lists.html.heex +++ /dev/null @@ -1,182 +0,0 @@ - -
- <.admin_page_header - back={Routes.path("/admin")} - title="Newsletter Lists" - subtitle="Manage your email subscriber lists" - /> - - <%!-- Actions Bar --%> -
- <.pk_link navigate="/admin/newsletters/lists/new" class="btn btn-sm btn-primary"> - <.icon name="hero-plus" class="w-4 h-4 mr-1" /> New List - -
- - <%!-- Confirm Modal --%> - <%= if @show_confirm_modal do %> - - <% end %> - - <%!-- Lists Table (desktop) --%> - <%= if length(@lists) > 0 do %> - - - <%!-- Lists Cards (mobile) --%> -
-
- <%= for list <- @lists do %> -
-
- <%!-- Header: name + status --%> -
-
- {list.name} - <%= if list.is_default do %> - Default - <% end %> -
- <%= if list.status == "active" do %> - Active - <% else %> - Archived - <% end %> -
- - <%!-- Body: key/value grid --%> -
-
Slug
-
{list.slug}
- -
Subscribers
-
{list.subscriber_count}
-
- - <%!-- Footer: actions --%> -
- <.pk_link - navigate={"/admin/newsletters/lists/#{list.uuid}/members"} - class="btn btn-xs btn-outline" - > - <.icon name="hero-users" class="w-3 h-3" /> Members - - <.pk_link - navigate={"/admin/newsletters/lists/#{list.uuid}/edit"} - class="btn btn-xs btn-outline" - > - <.icon name="hero-pencil" class="w-3 h-3" /> Edit - - -
-
-
- <% end %> -
-
- <% else %> -
- <.icon name="hero-list-bullet" class="w-16 h-16 mx-auto mb-4 opacity-30" /> -

No newsletter lists yet

-

- Create your first list to start managing subscribers. -

- <.pk_link navigate="/admin/newsletters/lists/new" class="btn btn-primary btn-sm"> - <.icon name="hero-plus" class="w-4 h-4 mr-1" /> Create First List - -
- <% end %> -
-
diff --git a/lib/modules/newsletters/web/unsubscribe_controller.ex b/lib/modules/newsletters/web/unsubscribe_controller.ex deleted file mode 100644 index 613307932..000000000 --- a/lib/modules/newsletters/web/unsubscribe_controller.ex +++ /dev/null @@ -1,63 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Web.UnsubscribeController do - use PhoenixKitWeb, :controller - - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Utils.Routes - - plug :put_view, html: PhoenixKit.Modules.Newsletters.Web.UnsubscribeHTML - - # GET /newsletters/unsubscribe?token=... - # Shows unsubscribe options page (per-list + global) - def unsubscribe(conn, %{"token" => token}) do - case Phoenix.Token.verify(PhoenixKitWeb.Endpoint, "unsubscribe", token, max_age: 604_800) do - {:ok, %{user_uuid: user_uuid, list_uuid: list_uuid}} -> - list = Newsletters.get_list(list_uuid) - all_lists = Newsletters.list_user_subscriptions(user_uuid) - - conn - |> assign(:token, token) - |> assign(:list, list) - |> assign(:all_lists, all_lists) - |> assign(:user_uuid, user_uuid) - |> render(:unsubscribe) - - {:error, _reason} -> - conn - |> put_flash(:error, "Invalid or expired unsubscribe link.") - |> redirect(to: Routes.path("/")) - end - end - - # POST /newsletters/unsubscribe — process the choice - def process_unsubscribe(conn, %{"token" => token, "scope" => "list"}) do - case Phoenix.Token.verify(PhoenixKitWeb.Endpoint, "unsubscribe", token, max_age: 604_800) do - {:ok, %{user_uuid: user_uuid, list_uuid: list_uuid}} -> - Newsletters.unsubscribe_user(list_uuid, user_uuid) - - conn - |> put_flash(:info, "You have been unsubscribed from this list.") - |> redirect(to: Routes.path("/")) - - _ -> - conn - |> put_flash(:error, "Invalid link.") - |> redirect(to: Routes.path("/")) - end - end - - def process_unsubscribe(conn, %{"token" => token, "scope" => "all"}) do - case Phoenix.Token.verify(PhoenixKitWeb.Endpoint, "unsubscribe", token, max_age: 604_800) do - {:ok, %{user_uuid: user_uuid}} -> - Newsletters.unsubscribe_from_all(user_uuid) - - conn - |> put_flash(:info, "You have been unsubscribed from all lists.") - |> redirect(to: Routes.path("/")) - - _ -> - conn - |> put_flash(:error, "Invalid link.") - |> redirect(to: Routes.path("/")) - end - end -end diff --git a/lib/modules/newsletters/web/unsubscribe_html.ex b/lib/modules/newsletters/web/unsubscribe_html.ex deleted file mode 100644 index bd828a91d..000000000 --- a/lib/modules/newsletters/web/unsubscribe_html.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Web.UnsubscribeHTML do - use PhoenixKitWeb, :html - - embed_templates "unsubscribe_html/*" -end diff --git a/lib/modules/newsletters/web/unsubscribe_html/unsubscribe.html.heex b/lib/modules/newsletters/web/unsubscribe_html/unsubscribe.html.heex deleted file mode 100644 index cdd3d6bda..000000000 --- a/lib/modules/newsletters/web/unsubscribe_html/unsubscribe.html.heex +++ /dev/null @@ -1,54 +0,0 @@ -
-
-
-

Manage Email Subscriptions

- - <%= if @list do %> -

- You are about to unsubscribe from {@list.name}. -

- -
- - - - -
- <% end %> - - <%= if length(@all_lists) > 1 do %> -
OR
- -
- You are also subscribed to: -
-
    - <%= for membership <- @all_lists, membership.list && (is_nil(@list) || membership.list.uuid != @list.uuid) do %> -
  • {membership.list.name}
  • - <% end %> -
- -
- - - - -
- <% end %> - - <%= if is_nil(@list) and length(@all_lists) <= 1 do %> -
- No active subscriptions found. -
- <% end %> -
-
-
diff --git a/lib/modules/newsletters/workers/delivery_worker.ex b/lib/modules/newsletters/workers/delivery_worker.ex deleted file mode 100644 index 33c05206d..000000000 --- a/lib/modules/newsletters/workers/delivery_worker.ex +++ /dev/null @@ -1,164 +0,0 @@ -defmodule PhoenixKit.Modules.Newsletters.Workers.DeliveryWorker do - @moduledoc """ - Oban worker for sending a single broadcast email to one recipient. - - ## Job Arguments - - - `delivery_uuid` - UUID of the Delivery record - - `broadcast_uuid` - UUID of the Broadcast record - - ## Queue Configuration - - Add to your Oban config (concurrency controls rate limiting): - - config :my_app, Oban, - queues: [newsletters_delivery: 10] - - The `newsletters_rate_limit` setting (default: 14 emails/sec) maps to queue concurrency. - Parent app should read `Settings.get_setting("newsletters_rate_limit", "10")` and apply to Oban queue config. - """ - - use Oban.Worker, - queue: :newsletters_delivery, - max_attempts: 3, - unique: [period: :infinity, keys: [:delivery_uuid], states: :incomplete] - - require Logger - - alias PhoenixKit.Modules.Emails.Template - alias PhoenixKit.Modules.Newsletters - alias PhoenixKit.Modules.Newsletters.Delivery - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Routes - - @impl Oban.Worker - def perform(%Oban.Job{ - args: %{"delivery_uuid" => delivery_uuid, "broadcast_uuid" => broadcast_uuid} - }) do - with {:ok, delivery} <- get_delivery(delivery_uuid), - {:ok, broadcast} <- get_broadcast(broadcast_uuid), - {:ok, user} <- get_user(delivery.user_uuid), - {:ok, html_body, text_body} <- render_email(broadcast, user), - {:ok, result} <- send_email(broadcast, user, html_body, text_body) do - message_id = Map.get(result, :id) - - Newsletters.update_delivery_status(delivery, "sent", %{ - sent_at: UtilsDate.utc_now(), - message_id: message_id - }) - - update_broadcast_counter(broadcast_uuid, :sent_count) - - :ok - else - {:error, reason} -> - Logger.error("DeliveryWorker: Failed delivery #{delivery_uuid}: #{inspect(reason)}") - handle_failure(delivery_uuid, broadcast_uuid, reason) - {:error, inspect(reason)} - end - end - - defp get_delivery(uuid) do - case repo().get(Delivery, uuid) do - nil -> {:error, :delivery_not_found} - delivery -> {:ok, delivery} - end - end - - defp get_broadcast(uuid) do - {:ok, Newsletters.get_broadcast!(uuid)} - rescue - Ecto.NoResultsError -> {:error, :broadcast_not_found} - end - - defp get_user(user_uuid) do - case repo().get(PhoenixKit.Users.Auth.User, user_uuid) do - nil -> {:error, :user_not_found} - user -> {:ok, user} - end - end - - defp render_email(broadcast, user) do - variables = build_variables(broadcast, user) - html = substitute_variables(broadcast.html_body || "", variables) - text = substitute_variables(broadcast.text_body || "", variables) - - html = maybe_apply_template(html, broadcast) - - {:ok, html, text} - end - - defp build_variables(broadcast, user) do - token_data = %{user_uuid: user.uuid, list_uuid: broadcast.list_uuid} - - endpoint = PhoenixKit.Config.get(:endpoint, PhoenixKitWeb.Endpoint) - - unsubscribe_token = - Phoenix.Token.sign(endpoint, "unsubscribe", token_data) - - unsubscribe_url = - Routes.url("/newsletters/unsubscribe?token=#{unsubscribe_token}") - - %{ - "name" => user.username || user.email, - "email" => user.email, - "unsubscribe_url" => unsubscribe_url - } - end - - defp substitute_variables(content, variables) do - Enum.reduce(variables, content, fn {key, value}, acc -> - String.replace(acc, "{{#{key}}}", to_string(value)) - end) - end - - defp maybe_apply_template(content, %{template_uuid: nil}), do: content - - defp maybe_apply_template(content, %{template_uuid: template_uuid}) do - case repo().get(Template, template_uuid) do - nil -> - content - - template -> - html = Template.get_translation(template.html_body, "en") - String.replace(html, "{{content}}", content) - end - end - - defp send_email(broadcast, user, html_body, text_body) do - from_email = PhoenixKit.Settings.get_setting("from_email", "noreply@example.com") - from_name = PhoenixKit.Settings.get_setting("from_name", "Newsletter") - - Swoosh.Email.new() - |> Swoosh.Email.to(user.email) - |> Swoosh.Email.from({from_name, from_email}) - |> Swoosh.Email.subject(broadcast.subject) - |> Swoosh.Email.html_body(html_body) - |> Swoosh.Email.text_body(text_body) - |> PhoenixKit.Mailer.deliver_email() - end - - defp handle_failure(delivery_uuid, broadcast_uuid, reason) do - case get_delivery(delivery_uuid) do - {:ok, delivery} -> - Newsletters.update_delivery_status(delivery, "failed", %{ - error: inspect(reason) - }) - - update_broadcast_counter(broadcast_uuid, :bounced_count) - - _ -> - :ok - end - end - - defp update_broadcast_counter(broadcast_uuid, field) do - import Ecto.Query - - PhoenixKit.Modules.Newsletters.Broadcast - |> where([b], b.uuid == ^broadcast_uuid) - |> repo().update_all(inc: [{field, 1}]) - end - - defp repo, do: PhoenixKit.RepoHelper.repo() -end diff --git a/lib/phoenix_kit/module_registry.ex b/lib/phoenix_kit/module_registry.ex index 02ffc92d7..d8c494511 100644 --- a/lib/phoenix_kit/module_registry.ex +++ b/lib/phoenix_kit/module_registry.ex @@ -395,7 +395,6 @@ defmodule PhoenixKit.ModuleRegistry do PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, PhoenixKit.Modules.Emails, - PhoenixKit.Modules.Newsletters, PhoenixKit.Modules.Entities, PhoenixKit.Modules.Languages, PhoenixKit.Modules.Legal, diff --git a/lib/phoenix_kit/scheduled_jobs/workers/process_scheduled_jobs_worker.ex b/lib/phoenix_kit/scheduled_jobs/workers/process_scheduled_jobs_worker.ex index 12f918880..a35ac84fb 100644 --- a/lib/phoenix_kit/scheduled_jobs/workers/process_scheduled_jobs_worker.ex +++ b/lib/phoenix_kit/scheduled_jobs/workers/process_scheduled_jobs_worker.ex @@ -39,7 +39,6 @@ defmodule PhoenixKit.ScheduledJobs.Workers.ProcessScheduledJobsWorker do require Logger - alias PhoenixKit.Modules.Newsletters alias PhoenixKit.Modules.Posts alias PhoenixKit.ScheduledJobs @@ -75,8 +74,12 @@ defmodule PhoenixKit.ScheduledJobs.Workers.ProcessScheduledJobsWorker do end # Catch-up: Send any broadcasts that are "scheduled" with past scheduled_at - if Newsletters.enabled?() do - {:ok, newsletters_count} = Newsletters.process_scheduled_broadcasts() + newsletters_mod = PhoenixKit.ModuleRegistry.get_by_key("newsletters") + + if newsletters_mod && Code.ensure_loaded?(newsletters_mod) && + function_exported?(newsletters_mod, :enabled?, 0) && + newsletters_mod.enabled?() do + {:ok, newsletters_count} = newsletters_mod.process_scheduled_broadcasts() if newsletters_count > 0 do Logger.info( diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index ebdf2f3f6..314879aab 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -102,7 +102,6 @@ defmodule PhoenixKitWeb.Integration do alias PhoenixKitWeb.Routes.BlogRoutes alias PhoenixKitWeb.Routes.CustomerServiceRoutes alias PhoenixKitWeb.Routes.EmailsRoutes - alias PhoenixKitWeb.Routes.NewslettersRoutes alias PhoenixKitWeb.Routes.PublishingRoutes alias PhoenixKitWeb.Routes.ReferralsRoutes alias PhoenixKitWeb.Routes.ShopRoutes @@ -429,7 +428,6 @@ defmodule PhoenixKitWeb.Integration do # Get external route module AST outside quote to avoid require/alias inside quote emails_admin = safe_route_call(EmailsRoutes, :admin_routes, []) - newsletters_admin = safe_route_call(NewslettersRoutes, :admin_routes, []) {tickets_admin, publishing_admin, referrals_admin} = if suffix == :_locale do @@ -753,7 +751,6 @@ defmodule PhoenixKitWeb.Integration do # Routes from external route modules unquote(emails_admin) - unquote(newsletters_admin) unquote(tickets_admin) unquote(publishing_admin) unquote(referrals_admin) @@ -1345,7 +1342,6 @@ defmodule PhoenixKitWeb.Integration do # Call route generators BEFORE quote block (aliases work in this context) # Uses safe_route_call/3 so modules can be safely extracted to separate packages emails_routes = safe_route_call(EmailsRoutes, :generate, [url_prefix]) - newsletters_routes = safe_route_call(NewslettersRoutes, :generate, [url_prefix]) publishing_routes = safe_route_call(PublishingRoutes, :generate, [url_prefix]) customer_service_routes = safe_route_call(CustomerServiceRoutes, :generate, [url_prefix]) blog_routes = safe_route_call(BlogRoutes, :generate, [url_prefix]) @@ -1365,7 +1361,6 @@ defmodule PhoenixKitWeb.Integration do # Generate module routes from separate files (improves compilation time) unquote(emails_routes) - unquote(newsletters_routes) unquote(publishing_routes) unquote(customer_service_routes) diff --git a/lib/phoenix_kit_web/routes/newsletters.ex b/lib/phoenix_kit_web/routes/newsletters.ex deleted file mode 100644 index 688bc25d6..000000000 --- a/lib/phoenix_kit_web/routes/newsletters.ex +++ /dev/null @@ -1,74 +0,0 @@ -defmodule PhoenixKitWeb.Routes.NewslettersRoutes do - @moduledoc """ - Newsletters module routes. - - Provides route definitions for newsletters admin interfaces and unsubscribe flow. - Separated to improve compilation time. - """ - - @doc """ - Returns quoted code for newsletters non-LiveView routes (unsubscribe). - """ - def generate(url_prefix) do - quote do - scope unquote(url_prefix) do - pipe_through [:browser] - - get "/newsletters/unsubscribe", - PhoenixKit.Modules.Newsletters.Web.UnsubscribeController, - :unsubscribe - - post "/newsletters/unsubscribe", - PhoenixKit.Modules.Newsletters.Web.UnsubscribeController, - :process_unsubscribe - end - end - end - - @doc """ - Returns quoted admin LiveView route declarations for inclusion in the shared admin live_session. - """ - def admin_routes do - quote do - live "/admin/newsletters/broadcasts", - PhoenixKit.Modules.Newsletters.Web.Broadcasts, - :index, - as: :newsletters_broadcasts - - live "/admin/newsletters/broadcasts/new", - PhoenixKit.Modules.Newsletters.Web.BroadcastEditor, - :new, - as: :newsletters_broadcast_new - - live "/admin/newsletters/broadcasts/:id/edit", - PhoenixKit.Modules.Newsletters.Web.BroadcastEditor, - :edit, - as: :newsletters_broadcast_edit - - live "/admin/newsletters/broadcasts/:id", - PhoenixKit.Modules.Newsletters.Web.BroadcastDetails, - :show, - as: :newsletters_broadcast_details - - live "/admin/newsletters/lists", - PhoenixKit.Modules.Newsletters.Web.Lists, - :index, - as: :newsletters_lists - - live "/admin/newsletters/lists/new", - PhoenixKit.Modules.Newsletters.Web.ListEditor, - :new, - as: :newsletters_list_new - - live "/admin/newsletters/lists/:id/edit", - PhoenixKit.Modules.Newsletters.Web.ListEditor, - :edit, - as: :newsletters_list_edit - - live "/admin/newsletters/lists/:id/members", - PhoenixKit.Modules.Newsletters.Web.ListMembers, - :index, - as: :newsletters_list_members - end - end -end diff --git a/test/phoenix_kit/module_registry_test.exs b/test/phoenix_kit/module_registry_test.exs index 93a0bc047..fadd9965f 100644 --- a/test/phoenix_kit/module_registry_test.exs +++ b/test/phoenix_kit/module_registry_test.exs @@ -166,10 +166,10 @@ defmodule PhoenixKit.ModuleRegistryTest do end describe "all_feature_keys/0" do - test "returns sorted list of 21 feature keys" do + test "returns sorted list of 20 feature keys" do keys = ModuleRegistry.all_feature_keys() assert is_list(keys) - assert length(keys) == 21 + assert length(keys) == 20 assert keys == Enum.sort(keys) end diff --git a/test/phoenix_kit/users/permissions_test.exs b/test/phoenix_kit/users/permissions_test.exs index ff646de62..0feb2c135 100644 --- a/test/phoenix_kit/users/permissions_test.exs +++ b/test/phoenix_kit/users/permissions_test.exs @@ -53,7 +53,7 @@ defmodule PhoenixKit.Users.PermissionsTest do assert "emails" in keys assert "entities" in keys assert "ai" in keys - assert length(keys) == 21 + assert length(keys) == 20 end test "does not include core keys" do @@ -70,8 +70,8 @@ defmodule PhoenixKit.Users.PermissionsTest do assert MapSet.new(all) == MapSet.new(expected) end - test "has 26 built-in keys" do - assert length(Permissions.all_module_keys()) == 26 + test "has 25 built-in keys" do + assert length(Permissions.all_module_keys()) == 25 end end From f3a5d7be49a3808129fcc7aca32773cb90ccc132 Mon Sep 17 00:00:00 2001 From: timujeen Date: Sat, 14 Mar 2026 23:42:27 +0000 Subject: [PATCH 06/10] =?UTF-8?q?Fix=20module=20public=20routes=20ordering?= =?UTF-8?q?=20=E2=80=94=20place=20before=20publishing=20catch-alls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/phoenix_kit_web/integration.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 314879aab..e90a22ee1 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -1359,6 +1359,10 @@ defmodule PhoenixKitWeb.Integration do # Generate basic routes scope unquote(generate_basic_scope(url_prefix)) + # Auto-discovered public routes from external modules MUST come before publishing/localized + # routes to prevent /:language/:group catch-alls from intercepting them (e.g., unsubscribe) + unquote_splicing(module_public_routes) + # Generate module routes from separate files (improves compilation time) unquote(emails_routes) unquote(publishing_routes) @@ -1376,9 +1380,6 @@ defmodule PhoenixKitWeb.Integration do # External route modules with public routes unquote_splicing(external_public_routes) - # Auto-discovered public routes from external modules (e.g., extracted newsletters unsubscribe) - unquote_splicing(module_public_routes) - # Generate catch-all route for pages at root level (must be last) unquote(generate_pages_catch_all()) end From 773a77752fa195c39a2cfba32c27322acbb9fc68 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Sun, 15 Mar 2026 13:23:47 +0000 Subject: [PATCH 07/10] Fix V15 migration: call Templates directly instead of SeedTemplates Mix task SeedTemplates.run() calls System.halt(1) on failure, which kills the migration process. Use Templates.seed_system_templates() directly (same approach as V31) so validation failures are logged gracefully without aborting the migration. --- lib/phoenix_kit/migrations/postgres/v15.ex | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/phoenix_kit/migrations/postgres/v15.ex b/lib/phoenix_kit/migrations/postgres/v15.ex index c112b7ed0..4a046a65b 100644 --- a/lib/phoenix_kit/migrations/postgres/v15.ex +++ b/lib/phoenix_kit/migrations/postgres/v15.ex @@ -24,7 +24,7 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do """ use Ecto.Migration - alias Mix.Tasks.PhoenixKit.SeedTemplates + alias PhoenixKit.Modules.Emails.Templates @doc """ Run the V15 migration to add email templates system. @@ -130,10 +130,10 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do # Private function to seed system email templates defp seed_system_templates do - case Code.ensure_loaded(SeedTemplates) do + case Code.ensure_loaded(Templates) do {:module, _} -> try do - SeedTemplates.run(["--quiet"]) + Templates.seed_system_templates() rescue error -> # Log the error but don't fail the migration @@ -142,9 +142,7 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do end {:error, _} -> - # Mix tasks may not be available in production builds - IO.puts("Info: SeedTemplates not available - skipping template seeding") - + # Templates module not available - skipping template seeding :ok end end From f2166d84e5bf9cdae6e181a8fe1b8e252e7a22c2 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Sun, 15 Mar 2026 13:24:53 +0000 Subject: [PATCH 08/10] Revert "Fix V15 migration: call Templates directly instead of SeedTemplates Mix task" This reverts commit 773a77752fa195c39a2cfba32c27322acbb9fc68. --- lib/phoenix_kit/migrations/postgres/v15.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/phoenix_kit/migrations/postgres/v15.ex b/lib/phoenix_kit/migrations/postgres/v15.ex index 4a046a65b..c112b7ed0 100644 --- a/lib/phoenix_kit/migrations/postgres/v15.ex +++ b/lib/phoenix_kit/migrations/postgres/v15.ex @@ -24,7 +24,7 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do """ use Ecto.Migration - alias PhoenixKit.Modules.Emails.Templates + alias Mix.Tasks.PhoenixKit.SeedTemplates @doc """ Run the V15 migration to add email templates system. @@ -130,10 +130,10 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do # Private function to seed system email templates defp seed_system_templates do - case Code.ensure_loaded(Templates) do + case Code.ensure_loaded(SeedTemplates) do {:module, _} -> try do - Templates.seed_system_templates() + SeedTemplates.run(["--quiet"]) rescue error -> # Log the error but don't fail the migration @@ -142,7 +142,9 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do end {:error, _} -> - # Templates module not available - skipping template seeding + # Mix tasks may not be available in production builds + IO.puts("Info: SeedTemplates not available - skipping template seeding") + :ok end end From 446cfc80201bab3affc3ad7e5819edf3146d36d6 Mon Sep 17 00:00:00 2001 From: Timujeen Date: Sun, 15 Mar 2026 13:25:08 +0000 Subject: [PATCH 09/10] Fix SeedTemplates: raise exception instead of System.halt on failure System.halt(1) kills the entire VM when called from migrations (V15 calls SeedTemplates.run() and has a try/rescue wrapper, but System.halt bypasses all rescue handlers). Raising an exception instead allows migration rescue blocks to handle the failure gracefully. --- lib/mix/tasks/phoenix_kit.seed_templates.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/phoenix_kit.seed_templates.ex b/lib/mix/tasks/phoenix_kit.seed_templates.ex index 625170cc2..949d43fb3 100644 --- a/lib/mix/tasks/phoenix_kit.seed_templates.ex +++ b/lib/mix/tasks/phoenix_kit.seed_templates.ex @@ -87,7 +87,7 @@ defmodule Mix.Tasks.PhoenixKit.SeedTemplates do {:error, :seed_failed} -> IO.puts("❌ Failed to seed some system templates. Check the logs for details.") - System.halt(1) + raise "Failed to seed some system templates" end end From 4f1b5eef221e96d870b489ee4c6f3914360335e0 Mon Sep 17 00:00:00 2001 From: timujeen Date: Sun, 15 Mar 2026 19:44:14 +0000 Subject: [PATCH 10/10] Add TableRowMenu component with card/table toggle for admin tables - Add TableRowMenu Phoenix component (table_row_menu.ex) with link, button, and divider sub-components supporting color variants - Add RowMenu JS hook using position:fixed to escape overflow-clip table containers; handles viewport edge detection, keyboard nav, and outside-click dismissal - Replace inline action buttons in Users, Roles, Sessions, Live Sessions, and Emails tables with dropdown menus - Add toggleable card/table view to Roles, Sessions, and Live Sessions tables (Users and Emails already had it); replaces manual md:hidden/md:block split in Sessions with unified table_default - Register TableRowMenu import in phoenix_kit_web.ex --- lib/modules/emails/web/emails.html.heex | 31 +- lib/phoenix_kit_web.ex | 1 + .../components/core/table_row_menu.ex | 217 ++++++++++++ .../live/users/live_sessions.html.heex | 296 +++++++++------- .../live/users/roles.html.heex | 116 +++++-- .../live/users/sessions.html.heex | 320 ++++++++---------- .../live/users/users.html.heex | 291 ++++++---------- priv/static/assets/phoenix_kit.js | 114 ++++++- 8 files changed, 853 insertions(+), 533 deletions(-) create mode 100644 lib/phoenix_kit_web/components/core/table_row_menu.ex diff --git a/lib/modules/emails/web/emails.html.heex b/lib/modules/emails/web/emails.html.heex index d910a742a..fa8868404 100644 --- a/lib/modules/emails/web/emails.html.heex +++ b/lib/modules/emails/web/emails.html.heex @@ -287,13 +287,14 @@ } > <:card_actions :let={log}> - + <.table_row_menu id={"email-card-menu-#{log.uuid}"} label={gettext("Email actions")}> + <.table_row_menu_button + phx-click="view_details" + phx-value-uuid={log.uuid} + icon="hero-eye" + label={gettext("View")} + /> + <.table_default_header> <.table_default_row> @@ -444,13 +445,17 @@ <% end %>
<% "actions" -> %> - + <.table_row_menu_button + phx-click="view_details" + phx-value-uuid={log.uuid} + icon="hero-eye" + label={gettext("View")} + /> + <% _ -> %> <% end %> diff --git a/lib/phoenix_kit_web.ex b/lib/phoenix_kit_web.ex index 2de2e8a4a..da103b2a2 100644 --- a/lib/phoenix_kit_web.ex +++ b/lib/phoenix_kit_web.ex @@ -124,6 +124,7 @@ defmodule PhoenixKitWeb do import PhoenixKitWeb.Components.Core.MessageTagBadge import PhoenixKitWeb.Components.Core.NumberFormatter import PhoenixKitWeb.Components.Core.TableDefault + import PhoenixKitWeb.Components.Core.TableRowMenu import PhoenixKitWeb.Components.Core.OAuthUtils import PhoenixKitWeb.Components.Core.OAuthProvider import PhoenixKitWeb.Components.Core.OAuthCheckbox diff --git a/lib/phoenix_kit_web/components/core/table_row_menu.ex b/lib/phoenix_kit_web/components/core/table_row_menu.ex new file mode 100644 index 000000000..bf9f2f5cd --- /dev/null +++ b/lib/phoenix_kit_web/components/core/table_row_menu.ex @@ -0,0 +1,217 @@ +defmodule PhoenixKitWeb.Components.Core.TableRowMenu do + @moduledoc """ + Dropdown action menu for table rows. + + Provides a compact "⋮" trigger button that opens a floating menu with action items. + Uses `position: fixed` via the `RowMenu` JS hook to escape `overflow-clip` table + containers — a common DaisyUI issue when dropdowns are nested inside tables. + + The menu is automatically positioned to stay within the viewport: it opens below + the trigger by default, flips above if there's no space below, and aligns to the + right edge of the trigger (shifting left if that would clip off-screen). + + Works on mobile and desktop. On mobile the menu is full-width clamped to viewport. + + ## Components + + - `table_row_menu/1` — wrapper with trigger button, accepts items as inner_block + - `table_row_menu_link/1` — navigation item (`navigate` or `href`) + - `table_row_menu_button/1` — action item (`phx-click` and other events) + - `table_row_menu_divider/1` — visual separator between groups + + ## Example + + <.table_row_menu id={"menu-\#{user.uuid}"}> + <.table_row_menu_link + navigate={Routes.path("/admin/users/view/\#{user.uuid}")} + icon="hero-eye" + label={gettext("View")} + /> + <.table_row_menu_link + navigate={Routes.path("/admin/users/edit/\#{user.uuid}")} + icon="hero-pencil" + label={gettext("Edit")} + variant="secondary" + /> + <.table_row_menu_divider /> + <.table_row_menu_button + phx-click="delete" + phx-value-id={user.uuid} + icon="hero-trash" + label={gettext("Delete")} + variant="error" + /> + + """ + + use Phoenix.Component + + import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] + + # --------------------------------------------------------------------------- + # table_row_menu — wrapper + # --------------------------------------------------------------------------- + + @doc """ + Renders the dropdown trigger and menu container. + + ## Attributes + + * `id` - Unique element ID (required). Used by the JS hook. + * `label` - Accessible label for the trigger button (optional, default: "Actions") + * `class` - Additional CSS classes for the wrapper (optional) + + ## Slots + + * `inner_block` - Menu items (use `table_row_menu_link`, `table_row_menu_button`, + `table_row_menu_divider`) + """ + attr :id, :string, required: true + attr :label, :string, default: "Actions" + attr :class, :string, default: nil + + slot :inner_block, required: true + + def table_row_menu(assigns) do + ~H""" +
+ <%!-- Trigger button --%> + + + <%!-- Floating menu — hidden by default, positioned via JS hook --%> + +
+ """ + end + + # --------------------------------------------------------------------------- + # table_row_menu_link — navigation item + # --------------------------------------------------------------------------- + + @doc """ + Renders a navigation link item inside the menu. + + ## Attributes + + * `navigate` - LiveView navigate path (optional) + * `href` - Regular href (optional) + * `icon` - Heroicon name, e.g. "hero-eye" (optional) + * `label` - Item label text (required) + * `variant` - Color variant: "default", "primary", "secondary", "info", "success", + "warning", "error" (optional, default: "default") + * `rest` - Additional HTML attributes passed to the `` element + """ + attr :navigate, :string, default: nil + attr :href, :string, default: nil + attr :icon, :string, default: nil + attr :label, :string, required: true + attr :variant, :string, default: "default" + attr :rest, :global + + def table_row_menu_link(assigns) do + ~H""" +
  • + <.link + navigate={@navigate} + href={@href} + role="menuitem" + class={[ + "flex items-center gap-2 px-3 py-2 rounded-lg text-sm", + "hover:bg-base-200 transition-colors cursor-pointer", + item_color_class(@variant) + ]} + {@rest} + > + <.icon :if={@icon} name={@icon} class="w-4 h-4 shrink-0 opacity-70" /> + {@label} + +
  • + """ + end + + # --------------------------------------------------------------------------- + # table_row_menu_button — action button item + # --------------------------------------------------------------------------- + + @doc """ + Renders an action button item inside the menu. + + ## Attributes + + * `icon` - Heroicon name, e.g. "hero-trash" (optional) + * `label` - Item label text (required) + * `variant` - Color variant: "default", "primary", "secondary", "info", "success", + "warning", "error" (optional, default: "default") + * `rest` - Additional HTML attributes (phx-click, phx-value-*, data-confirm, etc.) + """ + attr :icon, :string, default: nil + attr :label, :string, required: true + attr :variant, :string, default: "default" + attr :rest, :global + + def table_row_menu_button(assigns) do + ~H""" +
  • + +
  • + """ + end + + # --------------------------------------------------------------------------- + # table_row_menu_divider — separator + # --------------------------------------------------------------------------- + + @doc """ + Renders a visual separator between menu item groups. + """ + def table_row_menu_divider(assigns) do + ~H""" + + """ + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp item_color_class("primary"), do: "text-primary" + defp item_color_class("secondary"), do: "text-secondary" + defp item_color_class("info"), do: "text-info" + defp item_color_class("success"), do: "text-success" + defp item_color_class("warning"), do: "text-warning" + defp item_color_class("error"), do: "text-error" + defp item_color_class(_), do: "text-base-content" +end diff --git a/lib/phoenix_kit_web/live/users/live_sessions.html.heex b/lib/phoenix_kit_web/live/users/live_sessions.html.heex index 875a38ffd..a95689ad0 100644 --- a/lib/phoenix_kit_web/live/users/live_sessions.html.heex +++ b/lib/phoenix_kit_web/live/users/live_sessions.html.heex @@ -147,139 +147,181 @@ - <%!-- Sessions Table --%> -
    -
    - <.table_default variant="zebra"> - <.table_default_header> + <%!-- Sessions Table / Cards --%> + <.table_default + id="live-sessions-table" + variant="zebra" + toggleable={true} + items={@sessions} + card_fields={ + fn session -> + [ + %{label: "IP Address", value: session.ip_address || "Unknown"}, + %{label: "Current Page", value: session.current_page || "—"}, + %{ + label: "Connected", + value: Calendar.strftime(session.connected_at, "%H:%M:%S %d/%m/%Y") + }, + %{label: "Status", value: "● Active"} + ] + end + } + > + <:card_header :let={session}> +
    + <%= if session.type == :authenticated do %> + <% user = Map.get(@users_map, session.user_uuid) %> +
    + <.user_avatar user={user || %{email: session.user_email}} size="sm" /> +
    +
    {session.user_email}
    +
    + {session.user_uuid} +
    +
    +
    +
    + <.icon name="hero-shield-check" class="w-3 h-3 mr-1" /> Auth +
    + <% else %> +
    +
    + ? +
    +
    +
    Anonymous User
    +
    + {session.session_id} +
    +
    +
    +
    + <.icon name="hero-user" class="w-3 h-3 mr-1" /> Anon +
    + <% end %> +
    + + <.table_default_header> + <.table_default_row> + <.table_default_header_cell> + + + <.table_default_header_cell>User/Session + <.table_default_header_cell>IP Address + <.table_default_header_cell>Current Page + <.table_default_header_cell> + + + <.table_default_header_cell>Status + + + <.table_default_body> + <%= if Enum.empty?(@sessions) do %> + <.table_default_row> + <.table_default_cell class="text-center py-12" colspan={6}> +
    + <.icon name="hero-inbox" class="w-16 h-16 mx-auto" /> +
    +

    No active sessions

    +

    + There are currently no active sessions to display. +

    + + + <% else %> + <%= for session <- @sessions do %> <.table_default_row> - <.table_default_header_cell> - - - <.table_default_header_cell>User/Session - <.table_default_header_cell>IP Address - <.table_default_header_cell>Current Page - <.table_default_header_cell> - - - <.table_default_header_cell>Status - - - <.table_default_body> - <%= if Enum.empty?(@sessions) do %> - <.table_default_row> - <.table_default_cell class="text-center py-12" colspan={6}> -
    - <.icon name="hero-inbox" class="w-16 h-16 mx-auto" /> +
    + + <.table_default_cell> + <%= if session.type == :authenticated do %> + <% user = Map.get(@users_map, session.user_uuid) %> +
    + <.user_avatar user={user || %{email: session.user_email}} size="sm" /> +
    +
    {session.user_email}
    +
    ID: {session.user_uuid}
    +
    -

    No active sessions

    -

    - There are currently no active sessions to display. -

    - - - <% else %> - <%= for session <- @sessions do %> - <.table_default_row> - <.table_default_cell> -
    - <%= if session.type == :authenticated do %> -
    - <.icon name="hero-shield-check" class="w-3 h-3 mr-1" /> Auth -
    - <% else %> -
    - <.icon name="hero-user" class="w-3 h-3 mr-1" /> Anon -
    - <% end %> + <% else %> +
    +
    + ?
    - - <.table_default_cell> - <%= if session.type == :authenticated do %> - <% user = Map.get(@users_map, session.user_uuid) %> -
    - <.user_avatar user={user || %{email: session.user_email}} size="sm" /> -
    -
    - {session.user_email} -
    -
    - ID: {session.user_uuid} -
    -
    +
    +
    Anonymous User
    +
    + {session.session_id}
    - <% else %> -
    -
    - ? -
    -
    -
    - Anonymous User -
    -
    - {session.session_id} -
    -
    -
    - <% end %> - - <.table_default_cell> - - {session.ip_address || "Unknown"} - - - <.table_default_cell> - <%= if session.current_page do %> - - {session.current_page} - - <% else %> - - <% end %> - - <.table_default_cell> - <.time_ago datetime={session.connected_at} /> - - <.table_default_cell> -
    -
    - Active
    - - - <% end %> - <% end %> - - -
    -
    +
    + <% end %> + + <.table_default_cell> + + {session.ip_address || "Unknown"} + + + <.table_default_cell> + <%= if session.current_page do %> + + {session.current_page} + + <% else %> + + <% end %> + + <.table_default_cell> + <.time_ago datetime={session.connected_at} /> + + <.table_default_cell> +
    +
    + Active +
    + + + <% end %> + <% end %> + + <%!-- Pagination --%> <%= if @total_pages > 1 do %> diff --git a/lib/phoenix_kit_web/live/users/roles.html.heex b/lib/phoenix_kit_web/live/users/roles.html.heex index 8a0389f68..4d73f6d6f 100644 --- a/lib/phoenix_kit_web/live/users/roles.html.heex +++ b/lib/phoenix_kit_web/live/users/roles.html.heex @@ -94,7 +94,70 @@ <%!-- Roles List --%>
    <%= if length(@roles) > 0 do %> - <.table_default variant="zebra"> + <.table_default + id="roles-table" + variant="zebra" + toggleable={true} + items={@roles} + card_fields={ + fn role -> + [ + %{ + label: gettext("Type"), + value: if(role.is_system_role, do: gettext("System"), else: gettext("Custom")) + }, + %{label: gettext("Users"), value: Map.get(@role_stats, role.name, 0)}, + %{label: gettext("Description"), value: role.description || gettext("—")} + ] + end + } + > + <:card_header :let={role}> +
    + <.role_badge role={role} /> + <%= if role.is_system_role do %> + {gettext("System")} + <% else %> + {gettext("Custom")} + <% end %> +
    + + <:card_actions :let={role}> + <.table_row_menu id={"role-card-menu-#{role.uuid}"} label={gettext("Role actions")}> + <%= if !role.is_system_role do %> + <.table_row_menu_button + phx-click="show_edit_role" + phx-value-role_uuid={role.uuid} + icon="hero-pencil" + label={gettext("Edit")} + variant="primary" + /> + <.table_row_menu_divider /> + <.table_row_menu_button + phx-click="request_delete_role" + phx-value-role_uuid={role.uuid} + phx-value-role_name={role.name} + icon="hero-trash" + label={gettext("Delete")} + variant="error" + /> + <% else %> +
  • + {gettext("Protected")} +
  • + <% end %> + <%= if assigns[:phoenix_kit_current_scope] && PhoenixKit.Users.Auth.Scope.has_module_access?(assigns[:phoenix_kit_current_scope], "users") && !MapSet.member?(@uneditable_role_uuids, to_string(role.uuid)) do %> + <.table_row_menu_divider /> + <.table_row_menu_button + phx-click="show_permissions_editor" + phx-value-role_uuid={role.uuid} + icon="hero-key" + label={gettext("Permissions")} + variant="secondary" + /> + <% end %> + + <.table_default_header> <.table_default_row> <.table_default_header_cell>{gettext("Role")} @@ -139,49 +202,40 @@
    <.table_default_cell> -
    + <.table_row_menu id={"role-menu-#{role.uuid}"} label={gettext("Role actions")}> <%= if !role.is_system_role do %> - <%!-- Edit Button --%> - - - <%!-- Delete Button --%> - + icon="hero-trash" + label={gettext("Delete")} + variant="error" + /> <% else %> - +
  • {gettext("Protected")} - +
  • <% end %> - - <%!-- Permissions Button (users permission required, editable roles only) --%> <%= if assigns[:phoenix_kit_current_scope] && PhoenixKit.Users.Auth.Scope.has_module_access?(assigns[:phoenix_kit_current_scope], "users") && !MapSet.member?(@uneditable_role_uuids, to_string(role.uuid)) do %> - + icon="hero-key" + label={gettext("Permissions")} + variant="secondary" + /> <% end %> -
    + <% end %> diff --git a/lib/phoenix_kit_web/live/users/sessions.html.heex b/lib/phoenix_kit_web/live/users/sessions.html.heex index ee01555cc..3603f862c 100644 --- a/lib/phoenix_kit_web/live/users/sessions.html.heex +++ b/lib/phoenix_kit_web/live/users/sessions.html.heex @@ -166,203 +166,167 @@
    - <%!-- Sessions Table --%> - + <%!-- User Status --%> + <.table_default_cell> + <.user_status_badge + is_active={session.user_is_active} + confirmed_at={session.user_confirmed_at} + size={:sm} + /> + - <%!-- Sessions Cards (small screens only) --%> -
    - <%= if Enum.empty?(@sessions) do %> -
    - <%= if @total_count == 0 do %> - No active sessions found. - <% else %> - No sessions match your current filters. - <% end %> -
    - <% else %> -
    - <%= for session <- @sessions do %> -
    -
    - <%!-- Header: email + status --%> -
    -
    - {session.user_email} - {session.user_uuid} + <%!-- Created --%> + <.table_default_cell class="text-sm"> +
    +
    {UtilsDate.format_date_with_user_format(session.created_at)}
    +
    + {UtilsDate.format_time_with_user_format(session.created_at)}
    - <.user_status_badge - is_active={session.user_is_active} - confirmed_at={session.user_confirmed_at} - size={:sm} - />
    + - <%!-- Body: 2-column key/value grid --%> -
    -
    Token
    -
    - - {session.token_preview}... - -
    - -
    Created
    -
    - {UtilsDate.format_date_with_user_format(session.created_at)} - - {UtilsDate.format_time_with_user_format(session.created_at)} - -
    - -
    Age
    -
    - <.age_badge days={session.age_in_days} class="badge-sm h-auto" /> -
    + <%!-- Age --%> + <.table_default_cell> + <.age_badge days={session.age_in_days} class="badge-sm h-auto" /> + -
    Expires
    -
    - {UtilsDate.format_date_with_user_format(session.expires_at)} - - {UtilsDate.format_time_with_user_format(session.expires_at)} - + <%!-- Expires --%> + <.table_default_cell class="text-sm"> +
    +
    {UtilsDate.format_date_with_user_format(session.expires_at)}
    +
    + {UtilsDate.format_time_with_user_format(session.expires_at)}
    + - <%!-- Footer: actions --%> -
    - - -
    -
    -
    + icon="hero-exclamation-triangle" + label={gettext("Revoke all")} + variant="warning" + /> + + + <% end %> -
    - <% end %> -
    + <% end %> + + <%!-- Pagination --%> <%= if @total_pages > 1 do %> diff --git a/lib/phoenix_kit_web/live/users/users.html.heex b/lib/phoenix_kit_web/live/users/users.html.heex index 6d428a80a..5396c4dd6 100644 --- a/lib/phoenix_kit_web/live/users/users.html.heex +++ b/lib/phoenix_kit_web/live/users/users.html.heex @@ -320,134 +320,94 @@
    <% column_id == "actions" -> %> -
    - <%!-- View User Details --%> - <.link + <.table_row_menu + id={"user-menu-#{user.uuid}"} + label={gettext("User actions")} + > + <%!-- View --%> + <.table_row_menu_link navigate={ PhoenixKit.Utils.Routes.path("/admin/users/view/#{user.uuid}") } - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("View")} - > - <.icon name="hero-eye" class="h-4 w-4 hidden sm:inline" /> - {gettext("View")} - - <%!-- Edit User --%> - <.link + icon="hero-eye" + label={gettext("View")} + /> + <%!-- Edit --%> + <.table_row_menu_link navigate={ PhoenixKit.Utils.Routes.path("/admin/users/edit/#{user.uuid}") } - class="btn btn-xs btn-outline btn-secondary tooltip tooltip-bottom" - data-tip={gettext("Edit")} - > - <.icon name="hero-pencil" class="h-4 w-4 hidden sm:inline" /> - {gettext("Edit")} - - + icon="hero-pencil" + label={gettext("Edit")} + variant="secondary" + /> <%!-- Role Management --%> <%= if user.uuid != @phoenix_kit_current_user.uuid do %> - + icon="hero-user-group" + label={gettext("Roles")} + variant="primary" + /> <% end %> - - <%!-- Delete User --%> - <%= if user.uuid != @phoenix_kit_current_user.uuid && get_primary_role_name_unsafe(user) != "Owner" && Auth.can_delete_user?(user, @phoenix_kit_current_user) do %> - + <%!-- Current user: Settings --%> + <%= if user.uuid == @phoenix_kit_current_user.uuid do %> + <.table_row_menu_link + navigate={PhoenixKit.Utils.Routes.path("/dashboard/settings")} + icon="hero-cog-6-tooth" + label={gettext("Settings")} + variant="info" + /> <% end %> - - <%!-- Email Confirmation Toggle --%> + <%!-- Non-owner actions: confirmation, status, delete --%> <%= if user.uuid != @phoenix_kit_current_user.uuid && get_primary_role_name_unsafe(user) != "Owner" do %> - - - <%!-- Status Toggle --%> - - <% else %> - <%= if user.uuid == @phoenix_kit_current_user.uuid do %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/dashboard/settings")} - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("Settings")} - > - <.icon name="hero-cog-6-tooth" class="h-4 w-4 hidden sm:inline" /> - - {gettext("Settings")} - - - <% else %> - Protected + variant={if user.is_active, do: "success", else: "warning"} + /> + <%= if Auth.can_delete_user?(user, @phoenix_kit_current_user) do %> + <.table_row_menu_divider /> + <.table_row_menu_button + phx-click="request_delete_user" + phx-value-user_uuid={user.uuid} + data-confirm="Are you sure you want to delete this user? This action cannot be undone." + icon="hero-trash" + label={gettext("Delete")} + variant="error" + /> <% end %> <% end %> -
    + <% true -> %> <%!-- Handle custom fields and other columns --%> {render_column_cell( @@ -464,101 +424,66 @@ <% end %> <:card_actions :let={user}> -
    - <%!-- View User Details --%> - <.link + <.table_row_menu id={"user-card-menu-#{user.uuid}"} label={gettext("User actions")}> + <.table_row_menu_link navigate={PhoenixKit.Utils.Routes.path("/admin/users/view/#{user.uuid}")} - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("View")} - > - <.icon name="hero-eye" class="h-4 w-4 hidden sm:inline" /> - {gettext("View")} - - <%!-- Edit User --%> - <.link + icon="hero-eye" + label={gettext("View")} + /> + <.table_row_menu_link navigate={PhoenixKit.Utils.Routes.path("/admin/users/edit/#{user.uuid}")} - class="btn btn-xs btn-outline btn-secondary tooltip tooltip-bottom" - data-tip={gettext("Edit")} - > - <.icon name="hero-pencil" class="h-4 w-4 hidden sm:inline" /> - {gettext("Edit")} - - <%!-- Role Management --%> + icon="hero-pencil" + label={gettext("Edit")} + variant="secondary" + /> <%= if user.uuid != @phoenix_kit_current_user.uuid do %> - + icon="hero-user-group" + label={gettext("Roles")} + variant="primary" + /> <% end %> - <%!-- Delete User --%> - <%= if user.uuid != @phoenix_kit_current_user.uuid && get_primary_role_name_unsafe(user) != "Owner" && Auth.can_delete_user?(user, @phoenix_kit_current_user) do %> - + <%= if user.uuid == @phoenix_kit_current_user.uuid do %> + <.table_row_menu_link + navigate={PhoenixKit.Utils.Routes.path("/dashboard/settings")} + icon="hero-cog-6-tooth" + label={gettext("Settings")} + variant="info" + /> <% end %> - <%!-- Email Confirmation + Status Toggle --%> <%= if user.uuid != @phoenix_kit_current_user.uuid && get_primary_role_name_unsafe(user) != "Owner" do %> - - - <% else %> - <%= if user.uuid == @phoenix_kit_current_user.uuid do %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/dashboard/settings")} - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("Settings")} - > - <.icon name="hero-cog-6-tooth" class="h-4 w-4 hidden sm:inline" /> - {gettext("Settings")} - - <% else %> - Protected + icon={if user.is_active, do: "hero-check-circle", else: "hero-x-circle"} + label={if user.is_active, do: gettext("Deactivate"), else: gettext("Activate")} + variant={if user.is_active, do: "success", else: "warning"} + /> + <%= if Auth.can_delete_user?(user, @phoenix_kit_current_user) do %> + <.table_row_menu_divider /> + <.table_row_menu_button + phx-click="request_delete_user" + phx-value-user_uuid={user.uuid} + data-confirm="Are you sure you want to delete this user? This action cannot be undone." + icon="hero-trash" + label={gettext("Delete")} + variant="error" + /> <% end %> <% end %> -
    + diff --git a/priv/static/assets/phoenix_kit.js b/priv/static/assets/phoenix_kit.js index 2630daa8a..2e71e2212 100644 --- a/priv/static/assets/phoenix_kit.js +++ b/priv/static/assets/phoenix_kit.js @@ -1487,7 +1487,119 @@ if (typeof window.Chart === "undefined") { // ============================================================================ - // 5. EMAIL CHARTS HOOK + // 5. ROW MENU HOOK + // ============================================================================ + // + // Positions table row action dropdown menus using position: fixed to escape + // the overflow-x-auto overflow-y-clip table container — a common DaisyUI + // issue where dropdowns nested inside tables get clipped. + // + // The menu opens below the trigger by default, flips above if out of space, + // and aligns to the right edge (shifting left if that would clip off-screen). + // Closes on outside click, Escape key, and LiveView navigation. + // + // Usage in HEEX (via table_row_menu component): + // <.table_row_menu id={"row-menu-\#{item.uuid}"}> + // <.table_row_menu_link navigate={...} icon="hero-eye" label="View" /> + // <.table_row_menu_button phx-click="delete" icon="hero-trash" label="Delete" variant="error" /> + // + // + window.PhoenixKitHooks.RowMenu = { + mounted() { + this.trigger = this.el.querySelector("[data-row-menu-trigger]"); + this.menu = this.el.querySelector("[data-row-menu-content]"); + this.isOpen = false; + + this._onTriggerClick = (e) => { + e.stopPropagation(); + this.isOpen ? this._close() : this._open(); + }; + + this._onOutsideClick = (e) => { + if (!this.el.contains(e.target)) this._close(); + }; + + this._onKeydown = (e) => { + if (e.key === "Escape") { + this._close(); + this.trigger.focus(); + } + // Arrow key navigation inside menu + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + var items = Array.from(this.menu.querySelectorAll("[role='menuitem']:not([disabled])")); + if (items.length === 0) return; + var idx = items.indexOf(document.activeElement); + var next = e.key === "ArrowDown" + ? (idx + 1) % items.length + : (idx - 1 + items.length) % items.length; + items[next].focus(); + } + }; + + // Close when any item is clicked + this._onMenuClick = () => { this._close(); }; + + this.trigger.addEventListener("click", this._onTriggerClick); + this.menu.addEventListener("click", this._onMenuClick); + }, + + _open() { + var triggerRect = this.trigger.getBoundingClientRect(); + var vw = window.innerWidth; + var vh = window.innerHeight; + var gap = 4; + + // Show briefly to measure dimensions + this.menu.classList.remove("hidden"); + var menuWidth = this.menu.offsetWidth || 160; + var menuHeight = this.menu.offsetHeight || 200; + + // Horizontal: align right edge of menu with right edge of trigger + var left = triggerRect.right - menuWidth; + if (left < 8) left = triggerRect.left; + left = Math.max(8, Math.min(left, vw - menuWidth - 8)); + + // Vertical: prefer below, flip above if not enough space + var top = triggerRect.bottom + gap; + if (top + menuHeight > vh - 8 && triggerRect.top - menuHeight - gap > 8) { + top = triggerRect.top - menuHeight - gap; + } + top = Math.max(8, Math.min(top, vh - menuHeight - 8)); + + this.menu.style.top = top + "px"; + this.menu.style.left = left + "px"; + + this.isOpen = true; + this.trigger.setAttribute("aria-expanded", "true"); + + document.addEventListener("click", this._onOutsideClick, true); + document.addEventListener("keydown", this._onKeydown); + + // Focus first item for keyboard navigation + var first = this.menu.querySelector("[role='menuitem']"); + if (first) first.focus(); + }, + + _close() { + if (!this.isOpen) return; + this.menu.classList.add("hidden"); + this.isOpen = false; + this.trigger.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", this._onOutsideClick, true); + document.removeEventListener("keydown", this._onKeydown); + }, + + destroyed() { + this._close(); + this.trigger.removeEventListener("click", this._onTriggerClick); + this.menu.removeEventListener("click", this._onMenuClick); + } + }; + + + // ============================================================================ + // 6. EMAIL CHARTS HOOK // ============================================================================ // // Initializes Chart.js delivery trend and engagement charts on the email