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
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/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/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 %>
-
-
-
{@confirm_title}
-
{@confirm_message}
-
-
-
-
-
-
- <% 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 %>
-
- <% else %>
-
- <.table_default variant="zebra" size="sm" class="w-full">
- <.table_default_header>
- <.table_default_row>
- <.table_default_header_cell>Recipient
- <.table_default_header_cell>Status
- <.table_default_header_cell>Sent At
- <.table_default_header_cell>Delivered At
- <.table_default_header_cell>Opened At
- <.table_default_header_cell>Error
-
-
-
- <.table_default_body>
- <.table_default_row :for={delivery <- @deliveries}>
- <.table_default_cell class="font-mono text-sm">
- {if delivery.user, do: delivery.user.email, else: delivery.user_uuid}
-
- <.table_default_cell>
-
- {delivery.status}
-
-
- <.table_default_cell class="text-sm text-base-content/70">
- {format_datetime(delivery.sent_at)}
-
- <.table_default_cell class="text-sm text-base-content/70">
- {format_datetime(delivery.delivered_at)}
-
- <.table_default_cell class="text-sm text-base-content/70">
- {format_datetime(delivery.opened_at)}
-
- <.table_default_cell class="text-sm text-error max-w-xs truncate">
- {delivery.error || ""}
-
-
-
-
-
-
- <%!-- 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"
- />
-
-
-
-
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 %>
-
- <.table_default variant="zebra" size="sm" class="w-full">
- <.table_default_header>
- <.table_default_row>
- <.table_default_header_cell>Subject
- <.table_default_header_cell>List
- <.table_default_header_cell>Status
- <.table_default_header_cell class="text-right">Sent
- <.table_default_header_cell class="text-right">
- Delivered
-
- <.table_default_header_cell class="text-right">Opened
- <.table_default_header_cell class="text-right">Bounced
- <.table_default_header_cell>Sent At
-
-
-
- <.table_default_body>
- <.table_default_row
- :for={broadcast <- @broadcasts}
- class="hover cursor-pointer"
- phx-click="view_broadcast"
- phx-value-uuid={broadcast.uuid}
- >
- <.table_default_cell class="font-medium max-w-xs truncate">
- {broadcast.subject}
-
- <.table_default_cell>
- <%= if broadcast.list do %>
- {broadcast.list.name}
- <% else %>
- -
- <% end %>
-
- <.table_default_cell>
-
- {broadcast.status}
-
-
- <.table_default_cell class="text-right">{broadcast.sent_count}
- <.table_default_cell class="text-right">
- {broadcast.delivered_count}
-
- <.table_default_cell class="text-right">
- {broadcast.opened_count}
-
- <.table_default_cell class="text-right">
- {broadcast.bounced_count}
-
- <.table_default_cell class="text-sm text-base-content/70">
- {format_datetime(broadcast.sent_at)}
-
-
-
-
-
-
- <%!-- 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">
-
-
-
-
-
-
-
-
-
-
-
-
-
- <.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 %>
-
-
-
{@confirm_title}
-
{@confirm_message}
-
-
-
-
-
-
- <% 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 %>
-
- <.table_default variant="zebra" size="sm" class="w-full">
- <.table_default_header>
- <.table_default_row>
- <.table_default_header_cell>Email
- <.table_default_header_cell>Status
- <.table_default_header_cell>Subscribed At
- <.table_default_header_cell>Actions
-
-
-
- <.table_default_body>
- <%= for member <- @members do %>
- <.table_default_row>
- <.table_default_cell class="font-mono text-xs">
- <%= if member.user do %>
- {member.user.email}
- <% else %>
-
(unknown)
- <% end %>
-
- <.table_default_cell>
- <%= if member.status == "active" do %>
-
Active
- <% else %>
-
Unsubscribed
- <% end %>
-
- <.table_default_cell class="text-xs">
- <%= if member.subscribed_at do %>
- {Calendar.strftime(member.subscribed_at, "%Y-%m-%d %H:%M")}
- <% end %>
-
- <.table_default_cell>
-
- <%= if member.status == "active" do %>
-
- <% end %>
-
-
-
-
- <% end %>
-
-
-
-
- <%!-- 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 %>
-
-
-
{@confirm_title}
-
{@confirm_message}
-
-
-
-
-
-
- <% end %>
-
- <%!-- Lists Table (desktop) --%>
- <%= if length(@lists) > 0 do %>
-
- <.table_default variant="zebra" size="sm" class="w-full">
- <.table_default_header>
- <.table_default_row>
- <.table_default_header_cell>Name
- <.table_default_header_cell>Slug
- <.table_default_header_cell>Status
- <.table_default_header_cell>Subscribers
- <.table_default_header_cell>Actions
-
-
-
- <.table_default_body>
- <%= for list <- @lists do %>
- <.table_default_row>
- <.table_default_cell>
-
{list.name}
- <%= if list.is_default do %>
-
Default
- <% end %>
- <%= if list.description do %>
-
{list.description}
- <% end %>
-
- <.table_default_cell class="font-mono text-xs">
- {list.slug}
-
- <.table_default_cell>
- <%= if list.status == "active" do %>
-
Active
- <% else %>
-
Archived
- <% end %>
-
- <.table_default_cell>
- {list.subscriber_count}
-
- <.table_default_cell>
-
- <.pk_link
- navigate={"/admin/newsletters/lists/#{list.uuid}/members"}
- class="btn btn-xs btn-outline"
- title="View members"
- >
- <.icon name="hero-users" class="w-3 h-3 hidden sm:inline" />
- Members
-
- <.pk_link
- navigate={"/admin/newsletters/lists/#{list.uuid}/edit"}
- class="btn btn-xs btn-outline"
- title="Edit"
- >
- <.icon name="hero-pencil" class="w-3 h-3 hidden sm:inline" />
- Edit
-
-
-
-
-
- <% end %>
-
-
-
-
- <%!-- 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.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
diff --git a/lib/phoenix_kit/module_registry.ex b/lib/phoenix_kit/module_registry.ex
index 4b426c41d..d8c494511 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.
@@ -362,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,
@@ -413,6 +445,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/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.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 --%>
+
+ {render_slot(@inner_block)}
+
+
+ """
+ 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/integration.ex b/lib/phoenix_kit_web/integration.ex
index 46849254b..d5a9aa69c 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
@@ -754,7 +752,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)
@@ -1075,6 +1072,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)
@@ -1308,7 +1343,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])
@@ -1316,6 +1350,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())
@@ -1323,9 +1360,12 @@ 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(newsletters_routes)
unquote(publishing_routes)
unquote(customer_service_routes)
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 %>
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>
-