diff --git a/assets/css/app.css b/assets/css/app.css
index 8d301b19e..7d3657a4b 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -130,6 +130,8 @@
transform: rotate(-3deg);
}
}
+ /* Define the root bg color to be a slightly darker color than the base 100 used for contrast boxes */
+ --root-bg: var(--color-base-200);
}
@layer base {
diff --git a/config/config.exs b/config/config.exs
index db6523813..cdda21a8f 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -16,7 +16,7 @@ config :admin, Oban,
],
engine: Oban.Engines.Basic,
notifier: Oban.Notifiers.Postgres,
- queues: [default: 10, mailers: 1],
+ queues: [default: 10, mailing: 2],
repo: Admin.Repo
config :admin, :scopes,
diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex
index 37bc79fba..a77463626 100644
--- a/lib/admin/accounts.ex
+++ b/lib/admin/accounts.ex
@@ -389,6 +389,7 @@ defmodule Admin.Accounts do
def get_active_members do
Repo.all(
from(m in Account,
+ select: %{name: m.name, email: m.email, lang: fragment("?->>?", m.extra, "lang")},
where:
not is_nil(m.last_authenticated_at) and m.last_authenticated_at > ago(90, "day") and
m.type == "individual"
@@ -396,6 +397,15 @@ defmodule Admin.Accounts do
)
end
+ def get_members_by_language(language) do
+ Repo.all(
+ from(m in Account,
+ select: %{name: m.name, email: m.email, lang: fragment("?->>?", m.extra, "lang")},
+ where: fragment("?->>? = ?", m.extra, "lang", ^language) and m.type == "individual"
+ )
+ )
+ end
+
def create_member(attrs \\ %{}) do
%Account{}
|> Account.changeset(attrs)
diff --git a/lib/admin/accounts/account.ex b/lib/admin/accounts/account.ex
index 000208284..fcfe74330 100644
--- a/lib/admin/accounts/account.ex
+++ b/lib/admin/accounts/account.ex
@@ -9,6 +9,8 @@ defmodule Admin.Accounts.Account do
field :name, :string
field :email, :string
field :type, :string
+ field :extra, :map
+ field :last_authenticated_at, :utc_datetime
timestamps(type: :utc_datetime)
end
diff --git a/lib/admin/accounts/scope.ex b/lib/admin/accounts/scope.ex
index bdbbed08c..3bf627410 100644
--- a/lib/admin/accounts/scope.ex
+++ b/lib/admin/accounts/scope.ex
@@ -20,6 +20,8 @@ defmodule Admin.Accounts.Scope do
defstruct user: nil
+ @type t :: %__MODULE__{user: User.t() | nil}
+
@doc """
Creates a scope for the given user.
diff --git a/lib/admin/accounts/user_notifier.ex b/lib/admin/accounts/user_notifier.ex
index 7b36b4483..c43a01fb8 100644
--- a/lib/admin/accounts/user_notifier.ex
+++ b/lib/admin/accounts/user_notifier.ex
@@ -69,6 +69,36 @@ defmodule Admin.Accounts.UserNotifier do
)
end
+ def deliver_call_to_action(user, subject, message_text, button_text, button_url) do
+ html_body =
+ EmailTemplates.render("call_to_action", %{
+ name: user.name,
+ message: message_text,
+ button_text: button_text,
+ button_url: button_url
+ })
+
+ deliver(
+ user.email,
+ subject,
+ html_body,
+ """
+
+ ==============================
+
+ Hi #{user.name},
+
+ #{message_text}
+
+ #{button_text} #{button_url}
+
+ ==============================
+ #{@footer}
+ """,
+ reply_to: @support_email
+ )
+ end
+
@doc """
Deliver publication removal information.
"""
diff --git a/lib/admin/mailer_worker.ex b/lib/admin/mailer_worker.ex
deleted file mode 100644
index d89f6645c..000000000
--- a/lib/admin/mailer_worker.ex
+++ /dev/null
@@ -1,65 +0,0 @@
-defmodule Admin.MailerWorker do
- @moduledoc """
- Worker for sending notifications via email.
- """
-
- use Oban.Worker, queue: :mailers
-
- alias Admin.Accounts
- alias Admin.Accounts.Scope
- alias Admin.Accounts.UserNotifier
- alias Admin.Notifications
- alias Admin.Notifications.Notification
-
- @impl Oban.Worker
- def perform(%Oban.Job{
- args:
- %{
- "user_id" => user_id,
- "member_email" => member_email,
- "notification_id" => notification_id
- } =
- _args
- }) do
- user = Accounts.get_user!(user_id)
- scope = Scope.for_user(user)
-
- with {:ok, member} <- Accounts.get_member_by_email(member_email),
- {:ok, notification} <- Notifications.get_notification(scope, notification_id),
- {:ok, _} <-
- UserNotifier.deliver_notification(
- member,
- notification.title,
- notification.message
- ) do
- Notifications.save_log(
- scope,
- %{
- email: member.email,
- status: "sent"
- },
- notification
- )
-
- :ok
- else
- {:error, :member_not_found} ->
- Notifications.save_log(
- scope,
- %{
- email: member_email,
- status: "failed"
- },
- %Notification{id: notification_id}
- )
-
- {:cancel, :member_not_found}
-
- {:error, :notification_not_found} ->
- {:cancel, :notification_not_found}
-
- {:error, error} ->
- {:error, "Failed to send notification: #{inspect(error)}"}
- end
- end
-end
diff --git a/lib/admin/mailing_worker.ex b/lib/admin/mailing_worker.ex
new file mode 100644
index 000000000..23d5e45aa
--- /dev/null
+++ b/lib/admin/mailing_worker.ex
@@ -0,0 +1,115 @@
+defmodule Admin.MailingWorker do
+ @moduledoc """
+ Worker for sending batch emails to a target audience with internationalisation.
+ """
+
+ use Oban.Worker, queue: :mailing
+
+ alias Admin.Accounts
+ alias Admin.Accounts.Scope
+ alias Admin.Accounts.UserNotifier
+ alias Admin.Notifications
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{
+ args:
+ %{
+ "user_id" => user_id,
+ "notification_id" => notification_id
+ } =
+ _args
+ }) do
+ user = Accounts.get_user!(user_id)
+ scope = Scope.for_user(user)
+
+ with {:ok, notification} <- Notifications.get_notification(scope, notification_id),
+ included_langs = notification.localized_emails |> Enum.map(& &1.language),
+ {:ok, audience} <-
+ Notifications.get_target_audience(
+ scope,
+ notification.audience,
+ if(notification.use_strict_languages, do: [only_langs: included_langs], else: [])
+ ) do
+ # save number of recipients to the notification
+ Notifications.update_recipients(notification, %{total_recipients: length(audience)})
+ # start sending emails
+ send_emails(scope, notification, audience)
+ # await email progress messages
+ await_emails(scope, notification)
+ else
+ {:error, :notification_not_found} ->
+ {:cancel, :notification_not_found}
+
+ {:error, error} ->
+ {:error, "Failed to send notification: #{inspect(error)}"}
+ end
+ end
+
+ defp send_emails(scope, notification, audience) do
+ job_pid = self()
+
+ Task.async(fn ->
+ audience
+ |> Enum.with_index(1)
+ |> Enum.each(fn {user, index} ->
+ send_local_email(scope, user, notification)
+
+ current_progress = trunc(index / length(audience) * 100)
+
+ send(job_pid, {:progress, current_progress})
+
+ :timer.sleep(1000)
+ end)
+
+ send(job_pid, {:completed})
+ end)
+ end
+
+ defp send_local_email(scope, user, notification) do
+ # get the localized email
+ case Notifications.get_local_email_from_notification(notification, user.lang) do
+ nil ->
+ :skipped
+
+ localized_email ->
+ # deliver the email
+ UserNotifier.deliver_call_to_action(
+ user,
+ localized_email.subject,
+ localized_email.message,
+ localized_email.button_text,
+ localized_email.button_url
+ )
+
+ # save message log
+ Notifications.save_log(
+ scope,
+ %{
+ email: user.email,
+ status: "sent"
+ },
+ notification
+ )
+
+ :ok
+ end
+ end
+
+ defp await_emails(scope, notification) do
+ receive do
+ {:progress, percent} ->
+ Notifications.report_sending_progress(scope, {:progress, notification.name, percent})
+ await_emails(scope, notification)
+
+ {:completed} ->
+ Notifications.report_sending_progress(scope, {:completed, notification.name})
+
+ {:failed} ->
+ Notifications.report_sending_progress(scope, {:failed, notification.name})
+ after
+ 30_000 ->
+ Notifications.report_sending_progress(scope, {:failed, notification.name})
+ raise RuntimeError, "no progress after 30s"
+ end
+ end
+end
diff --git a/lib/admin/notifications.ex b/lib/admin/notifications.ex
index 6e8325820..695f1c99e 100644
--- a/lib/admin/notifications.ex
+++ b/lib/admin/notifications.ex
@@ -2,11 +2,14 @@ defmodule Admin.Notifications do
@moduledoc """
The Notifications context.
"""
+ require Logger
import Ecto.Query, warn: false
alias Admin.Repo
+ alias Admin.Accounts
alias Admin.Accounts.Scope
+ alias Admin.Notifications.LocalizedEmail
alias Admin.Notifications.Log
alias Admin.Notifications.Notification
@@ -26,16 +29,28 @@ defmodule Admin.Notifications do
Notification.changeset(notification, attrs, scope)
end
- def update_recipients(%Ecto.Changeset{} = notification, %{recipients: _} = attrs) do
+ def update_recipients(%Notification{} = notification, %{total_recipients: _} = attrs) do
Notification.update_recipients(notification, attrs)
end
+ def update_sent_at(%Scope{} = _scope, %Notification{} = notification) do
+ case from(n in Notification,
+ where: n.id == ^notification.id,
+ select: n,
+ update: [set: [sent_at: fragment("NOW()")]]
+ )
+ |> Repo.update_all([]) do
+ {1, notification} -> {:ok, notification}
+ {:error, error} -> {:error, error}
+ end
+ end
+
def create_notification(%Scope{} = scope, attrs) do
with {:ok, notification = %Notification{}} <-
change_notification(scope, %Notification{}, attrs)
|> Repo.insert() do
broadcast_notification(scope, {:created, notification})
- {:ok, notification |> Repo.preload([:logs])}
+ {:ok, notification |> Repo.preload([:logs, :localized_emails])}
end
end
@@ -57,6 +72,22 @@ defmodule Admin.Notifications do
Phoenix.PubSub.broadcast(Admin.PubSub, "notifications", message)
end
+ def subscribe_notifications(%Scope{} = _scope, notification_id) do
+ Phoenix.PubSub.subscribe(Admin.PubSub, "notifications:#{notification_id}")
+ end
+
+ defp broadcast_localized_email(%Scope{} = _scope, notification_id, message) do
+ Phoenix.PubSub.broadcast(Admin.PubSub, "notifications:#{notification_id}", message)
+ end
+
+ def subscribe_sending_progress(%Scope{} = _scope) do
+ Phoenix.PubSub.subscribe(Admin.PubSub, "notifications:sending")
+ end
+
+ def report_sending_progress(%Scope{} = _scope, message) do
+ Phoenix.PubSub.broadcast(Admin.PubSub, "notifications:sending", message)
+ end
+
@doc """
Returns the list of notifications.
@@ -67,7 +98,20 @@ defmodule Admin.Notifications do
"""
def list_notifications(%Scope{} = _scope) do
- Repo.all(Notification) |> Repo.preload([:logs])
+ Repo.all(from n in Notification, order_by: [desc: :updated_at])
+ |> Repo.preload([:logs, :localized_emails])
+ end
+
+ def list_notifications_by_status(%Scope{} = _scope) do
+ Repo.all(from n in Notification, order_by: [desc: :sent_at])
+ |> Repo.preload([:logs, :localized_emails])
+ end
+
+ def list_recently_sent_notifications(%Scope{} = _scope) do
+ Repo.all(
+ from n in Notification, where: not is_nil(n.sent_at), order_by: [desc: :sent_at], limit: 10
+ )
+ |> Repo.preload([:logs])
end
@doc """
@@ -85,7 +129,7 @@ defmodule Admin.Notifications do
"""
def get_notification!(%Scope{} = _scope, id) do
- Repo.get_by!(Notification, id: id) |> Repo.preload(:logs)
+ Repo.get_by!(Notification, id: id) |> Repo.preload([:logs, :localized_emails])
end
@doc """
@@ -101,7 +145,7 @@ defmodule Admin.Notifications do
"""
def get_notification(%Scope{} = _scope, id) do
- case Repo.get_by(Notification, id: id) |> Repo.preload(:logs) do
+ case Repo.get_by(Notification, id: id) |> Repo.preload([:logs, :localized_emails]) do
%Notification{} = notification -> {:ok, notification}
nil -> {:error, :notification_not_found}
end
@@ -129,6 +173,16 @@ defmodule Admin.Notifications do
end
end
+ def toggle_strict_languages(%Scope{} = scope, %Notification{} = notification) do
+ with {:ok, notification = %Notification{}} <-
+ notification
+ |> Notification.toggle_strict_languages()
+ |> Repo.update() do
+ broadcast_notification(scope, {:updated, notification})
+ {:ok, notification}
+ end
+ end
+
@doc """
Deletes a notification.
@@ -158,4 +212,135 @@ defmodule Admin.Notifications do
{:ok, log}
end
end
+
+ def change_localized_email(
+ %Scope{} = scope,
+ notification_id,
+ %LocalizedEmail{} = localized_email,
+ attrs
+ ) do
+ LocalizedEmail.changeset(localized_email, attrs, notification_id, scope)
+ end
+
+ def create_localized_email(%Scope{} = scope, notification_id, attrs) do
+ with {:ok, localized_email = %LocalizedEmail{}} <-
+ change_localized_email(scope, notification_id, %LocalizedEmail{}, attrs)
+ |> Repo.insert() do
+ broadcast_localized_email(
+ scope,
+ localized_email.notification_id,
+ {:created, localized_email}
+ )
+
+ {:ok, localized_email}
+ end
+ end
+
+ def update_localized_email(%Scope{} = scope, %LocalizedEmail{} = localized_email, attrs) do
+ with {:ok, localized_email = %LocalizedEmail{}} <-
+ change_localized_email(scope, localized_email.notification_id, localized_email, attrs)
+ |> Repo.update() do
+ broadcast_localized_email(
+ scope,
+ localized_email.notification_id,
+ {:updated, localized_email}
+ )
+
+ {:ok, localized_email}
+ end
+ end
+
+ def get_localized_email_by_lang!(%Scope{} = _scope, notification_id, language) do
+ case Repo.one(
+ from l in LocalizedEmail,
+ where:
+ l.notification_id == ^notification_id and
+ l.language == ^language,
+ limit: 1
+ ) do
+ nil -> raise "Localized email not found"
+ localized_email -> localized_email
+ end
+ end
+
+ def get_localized_email_by_lang(%Scope{} = _scope, notification_id, language) do
+ case Repo.one(
+ from l in LocalizedEmail,
+ where:
+ l.notification_id == ^notification_id and
+ l.language == ^language,
+ limit: 1
+ ) do
+ nil -> {:error, :not_found}
+ localized_email -> {:ok, localized_email}
+ end
+ end
+
+ def get_local_email_from_notification(notification, language) do
+ fallback_email =
+ notification.localized_emails
+ |> Enum.find(fn email -> email.language == notification.default_language end)
+
+ case notification.use_strict_languages do
+ true ->
+ notification.localized_emails
+ |> Enum.find(fn email -> email.language == language end)
+
+ false ->
+ notification.localized_emails
+ |> Enum.find(fn email -> email.language == language end) || fallback_email
+ end
+ end
+
+ def delete_localized_email(%Scope{} = scope, localized_email) do
+ with {:ok, localized_email = %LocalizedEmail{}} <-
+ Repo.delete(localized_email) do
+ broadcast_localized_email(
+ scope,
+ localized_email.notification_id,
+ {:deleted, localized_email}
+ )
+
+ {:ok, localized_email}
+ end
+ end
+
+ def get_target_audience(scope, target_audience, opts \\ [])
+
+ def get_target_audience(%Scope{} = _scope, "active", opts) do
+ audience =
+ Accounts.get_active_members()
+ |> Enum.map(&%{name: &1.name, email: &1.email, lang: &1.lang})
+ |> filter_audience_with_options(opts)
+
+ {:ok, audience}
+ end
+
+ def get_target_audience(%Scope{} = _scope, "french", opts) do
+ audience =
+ Accounts.get_members_by_language("fr")
+ |> Enum.map(&%{name: &1.name, email: &1.email, lang: &1.lang})
+ |> filter_audience_with_options(opts)
+
+ {:ok, audience}
+ end
+
+ def get_target_audience(%Scope{} = _scope, "graasp_team", opts) do
+ audience =
+ Accounts.list_users()
+ |> Enum.map(&%{name: &1.name, email: &1.email, lang: &1.language})
+ |> filter_audience_with_options(opts)
+
+ {:ok, audience}
+ end
+
+ def get_target_audience(%Scope{} = _scope, target_audience, _opts) do
+ Logger.error("Invalid target audience: #{target_audience}")
+ {:error, :invalid_target_audience}
+ end
+
+ defp filter_audience_with_options(audience, opts) do
+ only_langs = Keyword.get(opts, :only_langs, Admin.Languages.all_values()) |> MapSet.new()
+ audience |> Enum.filter(fn user -> MapSet.member?(only_langs, user.lang) end)
+ end
end
diff --git a/lib/admin/notifications/localized_email.ex b/lib/admin/notifications/localized_email.ex
new file mode 100644
index 000000000..101ef8aae
--- /dev/null
+++ b/lib/admin/notifications/localized_email.ex
@@ -0,0 +1,28 @@
+defmodule Admin.Notifications.LocalizedEmail do
+ @moduledoc """
+ Schema for storing localized emails.
+ """
+
+ use Admin.Schema
+ import Ecto.Changeset
+
+ schema "localized_emails" do
+ field :subject, :string
+ field :message, :string
+ field :button_text, :string
+ field :button_url, :string
+ field :language, :string, default: "en"
+
+ belongs_to :notification, Admin.Notifications.Notification
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(localized_email, attrs, notification_id, _user_scope) do
+ localized_email
+ |> cast(attrs, [:subject, :message, :button_text, :button_url, :language])
+ |> validate_required([:subject, :message, :language])
+ |> validate_inclusion(:language, ["en", "fr", "es", "it", "de"])
+ |> put_change(:notification_id, notification_id)
+ end
+end
diff --git a/lib/admin/notifications/notification.ex b/lib/admin/notifications/notification.ex
index d8a783f11..f0a6d94e4 100644
--- a/lib/admin/notifications/notification.ex
+++ b/lib/admin/notifications/notification.ex
@@ -7,95 +7,46 @@ defmodule Admin.Notifications.Notification do
import Ecto.Changeset
schema "notifications" do
- field :title, :string
- field :message, :string
- field :recipients, {:array, :string}
+ field :name, :string
+ field :audience, :string
+ field :default_language, :string
+ field :use_strict_languages, :boolean, default: false
+ field :total_recipients, :integer, default: 0
+ field :sent_at, :utc_datetime
has_many :logs, Admin.Notifications.Log
+
+ has_many :localized_emails, Admin.Notifications.LocalizedEmail,
+ preload_order: [asc: :language]
+
timestamps(type: :utc_datetime)
end
@doc false
def changeset(notification, attrs, _user_scope) do
notification
- |> cast(attrs, [:title, :message, :recipients])
- |> validate_required([:title, :message, :recipients])
- |> normalize_emails(:recipients)
- |> validate_email_list(:recipients)
+ |> cast(attrs, [:name, :audience, :default_language, :use_strict_languages, :total_recipients])
+ |> validate_required([:name, :audience, :default_language])
+ |> validate_inclusion(:default_language, ["en", "fr", "es", "it", "de"])
+ |> validate_inclusion(:use_strict_languages, [true, false])
end
- def update_recipients(notification, %{recipients: _} = attrs) do
+ def update_recipients(notification, %{total_recipients: _} = attrs) do
notification
- |> cast(attrs, [:recipients])
- |> validate_required([:recipients])
- |> normalize_emails(:recipients)
- |> validate_email_list(:recipients)
- end
-
- # Normalize each email string: trim, downcase, drop empty values
- defp normalize_emails(changeset, key) do
- case get_change(changeset, key) do
- nil ->
- changeset
-
- emails when is_list(emails) ->
- cleaned =
- emails
- |> Enum.map(&normalize_email_item/1)
- |> Enum.reject(&(&1 == ""))
-
- put_change(changeset, key, cleaned)
-
- _other ->
- # If a non-list sneaks in, leave as-is; validate_emails_list will add an error
- changeset
- end
- end
-
- defp normalize_email_item(item) when is_binary(item) do
- item
- |> String.trim()
- |> String.downcase()
+ |> cast(attrs, [:total_recipients])
+ |> validate_required([:total_recipients])
end
- defp normalize_email_item(_), do: ""
-
- # Validate the list and each element
- defp validate_email_list(changeset, key) do
- # Ensure it's a list
- changeset =
- validate_change(changeset, key, fn key, value ->
- if is_list(value) do
- []
- else
- [%{key => "must be a list of strings"}]
- end
- end)
-
- recipients = get_field(changeset, key) || []
-
- # Validate each item is a binary and matches email format
- changeset =
- Enum.with_index(recipients)
- |> Enum.reduce(changeset, fn {email, idx}, acc ->
- cond do
- not is_binary(email) ->
- add_error(acc, key, "item at index #{idx} must be a string")
-
- not valid_email?(email) ->
- add_error(acc, key, "invalid email at index #{idx}: #{email}")
-
- true ->
- acc
- end
- end)
-
- changeset
+ def toggle_strict_languages(notification) do
+ notification
+ # cast to changeset, but do not use any attr values
+ |> change(%{})
+ |> put_change(:use_strict_languages, !notification.use_strict_languages)
end
- # Pragmatic email validator; replace with your preferred validator if available.
- defp valid_email?(email) when is_binary(email) do
- # Simple, commonly used pattern; not fully RFC-compliant but practical.
- Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, email)
+ def set_sent_at(notification) do
+ notification
+ |> change(%{})
+ |> put_change(:sent_at, DateTime.utc_now())
end
end
diff --git a/lib/admin_web/components/core_components.ex b/lib/admin_web/components/core_components.ex
index 8a00b62bd..0d192b4d4 100644
--- a/lib/admin_web/components/core_components.ex
+++ b/lib/admin_web/components/core_components.ex
@@ -532,4 +532,47 @@ defmodule AdminWeb.CoreComponents do
"""
end
+
+ attr :severity, :string, default: "info", values: ["info", "success", "warning", "error"]
+ attr :variant, :string, values: ["soft", "outline", "dash"]
+ slot :inner_block, required: true
+ slot :action, required: false
+
+ def alert(assigns) do
+ assigns =
+ assign(
+ assigns,
+ :icon_name,
+ case assigns.severity do
+ "info" -> "hero-info-circle"
+ "success" -> "hero-check-circle"
+ "warning" -> "hero-exclamation-triangle"
+ "error" -> "hero-x-circle"
+ end
+ )
+
+ ~H"""
+
+ <.icon name={@icon_name} class="w-6 h-6 mr-2" />
+
+ {render_slot(@inner_block)}
+
+
+ <%= for action <- @action do %>
+ {render_slot(action)}
+ <% end %>
+
+
+ """
+ end
end
diff --git a/lib/admin_web/components/mailing_components.ex b/lib/admin_web/components/mailing_components.ex
new file mode 100644
index 000000000..2af278f86
--- /dev/null
+++ b/lib/admin_web/components/mailing_components.ex
@@ -0,0 +1,32 @@
+defmodule AdminWeb.MailingComponents do
+ @moduledoc """
+ Components for the mailing module.
+ """
+
+ use Phoenix.Component
+
+ attr :id, :string, required: true
+ attr :mailing, :map, required: true
+
+ def sent_mailing(assigns) do
+ ~H"""
+
+
+
+
{@mailing.name}
+
+ <%= for lang <- @mailing.localized_emails |> Enum.map(& &1.language) do %>
+
+ {lang}
+
+ <% end %>
+
+
Target Audience: {@mailing.audience}
+
+
+
{length(@mailing.logs)} / {@mailing.total_recipients}
+
+
+ """
+ end
+end
diff --git a/lib/admin_web/controllers/admin_html/dashboard.html.heex b/lib/admin_web/controllers/admin_html/dashboard.html.heex
index 72d5ff6f5..143127a53 100644
--- a/lib/admin_web/controllers/admin_html/dashboard.html.heex
+++ b/lib/admin_web/controllers/admin_html/dashboard.html.heex
@@ -3,7 +3,7 @@
Welcome, {@current_scope.user.name || @current_scope.user.email}
-
+
Users
{@user_stats.total}
@@ -11,7 +11,7 @@
-
+
Confirmed Users
{@user_stats.confirmed}
diff --git a/lib/admin_web/controllers/published_item_html.ex b/lib/admin_web/controllers/published_item_html.ex
index 01524e304..34f2c8d14 100644
--- a/lib/admin_web/controllers/published_item_html.ex
+++ b/lib/admin_web/controllers/published_item_html.ex
@@ -24,7 +24,7 @@ defmodule AdminWeb.PublishedItemHTML do
def publication_row(assigns) do
~H"""
-
+
<%= if Map.get(@publication, :thumbnails) |> Map.get(:small) do %>
<.thumbnail
diff --git a/lib/admin_web/email_templates/templates_html/call_to_action.html.heex b/lib/admin_web/email_templates/templates_html/call_to_action.html.heex
new file mode 100644
index 000000000..03a642560
--- /dev/null
+++ b/lib/admin_web/email_templates/templates_html/call_to_action.html.heex
@@ -0,0 +1,35 @@
+<.layout title="Graasp">
+
+
+ Hi {@name},
+
+
+ {@message}
+
+ <%= if @button_text do %>
+
+ {@button_text}
+
+
+ In case you can not click the button above here is the link:
+
+
+ {@button_url}
+
+ <% end %>
+
+
+ <:footer>
+
+
+ You are receiving this email because you have an account on Graasp.
+
+
+ Graasp Association, Valais, Switzerland
+
+
+
+
diff --git a/lib/admin_web/live/notification_live/form.ex b/lib/admin_web/live/notification_live/form.ex
new file mode 100644
index 000000000..282cbce24
--- /dev/null
+++ b/lib/admin_web/live/notification_live/form.ex
@@ -0,0 +1,211 @@
+defmodule AdminWeb.NotificationLive.Form do
+ use AdminWeb, :live_view
+
+ alias Admin.Notifications
+ alias Admin.Notifications.Notification
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ {@page_title}
+
+
+ <.form
+ :let={form}
+ for={@form}
+ id="notification-form"
+ phx-change="validate"
+ phx-submit="submit"
+ >
+ <.input autofocus field={form[:name]} type="text" label="Name" phx-debounce="500" />
+
+ <.input
+ field={form[:audience]}
+ type="select"
+ prompt="Select the audience"
+ options={[
+ [key: "Active users", value: "active"],
+ [key: "French speaking users", value: "french"],
+ [key: "Graasp team", value: "graasp_team"],
+ [key: "Inactive users", value: "inactive", disabled: true],
+ [key: "All users", value: "all", disabled: true]
+ ]}
+ label="Target Audience"
+ />
+
+ <%= if Ecto.Changeset.get_change(@form, :audience) != nil do %>
+
+ <.button
+ :if={form[:audience].value != ""}
+ type="button"
+ phx-click="fetch_recipients"
+ phx-value-audience={form[:audience].value}
+ >
+ Fetch Recipients
+
+ <%= if @recipients != [] do %>
+
+
+ {length(@recipients)} recipients for this audience (click to show)
+
+
+
+ <%= for %{name: name, email: email} <- @recipients do %>
+ - {name} ({email})
+ <% end %>
+
+
+
+ <% end %>
+
+ <% end %>
+
+ <.input
+ field={form[:default_language]}
+ type="select"
+ prompt="Select the default language"
+ label="Default Language"
+ options={Admin.Languages.all_options()}
+ />
+
+
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(params, _session, socket) do
+ socket =
+ socket
+ |> apply_action(socket.assigns.live_action, params)
+
+ {:ok, socket}
+ end
+
+ defp apply_action(socket, :new, _params) do
+ notification = %Notification{}
+
+ changeset =
+ Notifications.change_notification(socket.assigns.current_scope, notification, %{
+ "name" => "",
+ "audience" => "",
+ "default_language" => "",
+ "use_strict_languages" => false
+ })
+
+ socket
+ |> assign(:page_title, "New Mail")
+ |> assign(:notification, notification)
+ |> assign(:form, changeset)
+ |> assign(:recipients, [])
+ |> assign(:return_to, "index")
+ end
+
+ defp apply_action(socket, :edit, %{"notification_id" => id}) do
+ notification = Notifications.get_notification!(socket.assigns.current_scope, id)
+ included_langs = notification.localized_emails |> Enum.map(& &1.language)
+
+ {:ok, recipients} =
+ Notifications.get_target_audience(
+ socket.assigns.current_scope,
+ notification.audience,
+ if(notification.use_strict_languages, do: [only_langs: included_langs], else: [])
+ )
+
+ socket
+ |> assign(:page_title, "Edit Mail")
+ |> assign(:notification, notification)
+ |> assign(
+ :form,
+ Notifications.change_notification(socket.assigns.current_scope, notification)
+ )
+ |> assign(:recipients, recipients)
+ |> assign(:return_to, "show")
+ end
+
+ @impl true
+ def handle_event("fetch_recipients", %{"audience" => audience}, socket) do
+ {:ok, recipients} =
+ Notifications.get_target_audience(socket.assigns.current_scope, audience)
+
+ socket = socket |> assign(:recipients, recipients)
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("validate", %{"notification" => params}, socket) do
+ changeset =
+ Notifications.change_notification(socket.assigns.current_scope, %Notification{}, params)
+ |> Map.put(:action, :validate)
+
+ {:noreply, socket |> assign(:form, changeset)}
+ end
+
+ @impl true
+ def handle_event("submit", %{"notification" => params}, socket) do
+ save_email_notification(socket, socket.assigns.live_action, params)
+ end
+
+ defp save_email_notification(socket, :new, params) do
+ case Notifications.create_notification(socket.assigns.current_scope, params) do
+ {:ok, %Notification{} = notif} ->
+ {:noreply,
+ socket
+ |> push_navigate(to: ~p"/admin/notifications/#{notif}")}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply,
+ socket
+ |> assign(:form, changeset)}
+ end
+ end
+
+ defp save_email_notification(socket, :edit, params) do
+ case Notifications.update_notification(
+ socket.assigns.current_scope,
+ socket.assigns.notification,
+ params
+ ) do
+ {:ok, %Notification{} = notif} ->
+ {:noreply,
+ socket
+ |> push_navigate(to: ~p"/admin/notifications/#{notif}")}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply,
+ socket
+ |> assign(:form, changeset)}
+ end
+ end
+
+ defp return_path(_scope, "show", notification), do: ~p"/admin/notifications/#{notification}"
+ defp return_path(_scope, _, _), do: ~p"/admin/notifications"
+end
diff --git a/lib/admin_web/live/notification_live/index.ex b/lib/admin_web/live/notification_live/index.ex
index 9a3ab580e..f11d314c3 100644
--- a/lib/admin_web/live/notification_live/index.ex
+++ b/lib/admin_web/live/notification_live/index.ex
@@ -15,20 +15,65 @@ defmodule AdminWeb.NotificationLive.Index do
- <%!-- Idea: represent the mails as cards ? --%>
+ <%= if @sending_status do %>
+
+
{@sending_status.message}
+ <%= if @sending_status.status == :info do %>
+
+ {@sending_status.progress}%
+ <% end %>
+
+ <% end %>
+
+
Drafts
<.table
- id="notifications"
- rows={@streams.notifications}
+ id="wip_notifications"
+ rows={@streams.wip_notifications}
+ row_id={fn {_id, notification} -> "notifications-#{notification.id}" end}
row_click={
fn {_id, notification} -> JS.navigate(~p"/admin/notifications/#{notification}") end
}
>
- <:col :let={{_id, notification}} label="Title">{notification.title}
- <:col :let={{_id, notification}} label="Message">{notification.message}
- <:col :let={{_id, notification}} label="Recipients">
- {length(notification.recipients || [])}
+ <:col :let={{_id, notification}} label="Name">{notification.name}
+ <:col :let={{_id, notification}} label="Audience">{notification.audience}
+ <:action :let={{_id, notification}}>
+
+ <.link navigate={~p"/admin/notifications/#{notification}"}>Show
+
+
+ <:action :let={{id, notification}}>
+ <.link
+ class="text-error"
+ phx-click={JS.push("delete_wip", value: %{id: notification.id}) |> hide("##{id}")}
+ data-confirm="Are you sure?"
+ >
+ Delete
+
+
+
+
+
Sent
+
+ <.table
+ id="notifications"
+ rows={@streams.sent_notifications}
+ row_click={
+ fn {_id, notification} -> JS.navigate(~p"/admin/notifications/#{notification}/archive") end
+ }
+ >
+ <:col :let={{_id, notification}} label="Name">{notification.name}
+ <:col :let={{_id, notification}} label="Audience">{notification.audience}
+ <:col :let={{_id, notification}} label="Default Language">
+ {notification.default_language}
<:col :let={{_id, notification}} label="Sent">{length(notification.logs)}
+ <:col :let={{_id, notification}} label="Sent On">{notification.sent_at || "Not Sent"}
+ <:col :let={{_id, notification}} label="Total">{notification.total_recipients}
<:action :let={{_id, notification}}>
<.link navigate={~p"/admin/notifications/#{notification}"}>Show
@@ -37,7 +82,7 @@ defmodule AdminWeb.NotificationLive.Index do
<:action :let={{id, notification}}>
<.link
class="text-error"
- phx-click={JS.push("delete", value: %{id: notification.id}) |> hide("##{id}")}
+ phx-click={JS.push("delete_sent", value: %{id: notification.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
@@ -52,20 +97,47 @@ defmodule AdminWeb.NotificationLive.Index do
def mount(_params, _session, socket) do
if connected?(socket) do
Notifications.subscribe_notifications(socket.assigns.current_scope)
+
+ Notifications.subscribe_sending_progress(socket.assigns.current_scope)
end
+ notifications = Notifications.list_notifications_by_status(socket.assigns.current_scope)
+
+ {wip_notifications, sent_notifications} =
+ notifications |> Enum.split_with(&(&1.sent_at == nil))
+
{:ok,
socket
|> assign(:page_title, "Mailing")
- |> stream(:notifications, Notifications.list_notifications(socket.assigns.current_scope))}
+ |> assign(:sending_status, nil)
+ |> stream(
+ :wip_notifications,
+ wip_notifications
+ )
+ |> stream(
+ :sent_notifications,
+ sent_notifications
+ )}
end
@impl true
- def handle_event("delete", %{"id" => id}, socket) do
- notification = Notifications.get_notification!(socket.assigns.current_scope, id)
- {:ok, _} = Notifications.delete_notification(socket.assigns.current_scope, notification)
+ def handle_event("delete_sent", %{"id" => id}, socket) do
+ notification = delete_notification(socket.assigns.current_scope, id)
+
+ {:noreply, stream_delete(socket, :sent_notifications, notification)}
+ end
+
+ @impl true
+ def handle_event("delete_wip", %{"id" => id}, socket) do
+ notification = delete_notification(socket.assigns.current_scope, id)
+
+ {:noreply, stream_delete(socket, :wip_notifications, notification)}
+ end
- {:noreply, stream_delete(socket, :notifications, notification)}
+ defp delete_notification(scope, id) do
+ notification = Notifications.get_notification!(scope, id)
+ {:ok, _} = Notifications.delete_notification(scope, notification)
+ notification
end
@impl true
@@ -79,4 +151,38 @@ defmodule AdminWeb.NotificationLive.Index do
reset: true
)}
end
+
+ def handle_info({:progress, notification_name, percent}, socket) do
+ {:noreply,
+ assign(
+ socket,
+ :sending_status,
+ %{status: :info, message: "Sending: #{notification_name}", progress: percent}
+ )}
+ end
+
+ def handle_info({:completed, notification_name}, socket) do
+ # schedule an event to be sent in 10 seconds to clear the sending status
+ :timer.send_after(10_000, "clear_sending_status")
+
+ {:noreply,
+ assign(
+ socket,
+ :sending_status,
+ %{status: :success, message: "#{notification_name} has been sent successfully."}
+ )}
+ end
+
+ def handle_info({:failed, notification_name}, socket) do
+ {:noreply,
+ assign(
+ socket,
+ :sending_status,
+ %{status: :error, message: "#{notification_name} encountered an error."}
+ )}
+ end
+
+ def handle_info("clear_sending_status", socket) do
+ {:noreply, assign(socket, :sending_status, nil)}
+ end
end
diff --git a/lib/admin_web/live/notification_live/message_live/form.ex b/lib/admin_web/live/notification_live/message_live/form.ex
new file mode 100644
index 000000000..d377ca8e3
--- /dev/null
+++ b/lib/admin_web/live/notification_live/message_live/form.ex
@@ -0,0 +1,192 @@
+defmodule AdminWeb.NotificationMessageLive.Form do
+ use AdminWeb, :live_view
+
+ alias Admin.Notifications
+ alias Admin.Notifications.LocalizedEmail
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ Add a Message
+
+
+ <.form
+ :let={form}
+ for={@form}
+ id="notification-message-form"
+ phx-change="validate"
+ phx-submit="submit"
+ >
+ <.input
+ field={form[:language]}
+ type="select"
+ prompt="Select the language"
+ options={@language_options}
+ label="Language"
+ />
+ <.input field={form[:subject]} type="text" label="Subject" />
+ <.input field={form[:message]} type="textarea" label="Message" rows={5} phx-debounce="500" />
+ <.input field={form[:button_text]} type="text" label="Button Text" phx-debounce="500" />
+ <.input field={form[:button_url]} type="url" label="Button URL" phx-debounce="500" />
+
+
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(%{"notification_id" => notification_id} = params, _session, socket) do
+ notification = Notifications.get_notification!(socket.assigns.current_scope, notification_id)
+ already_assigned_langs = notification.localized_emails |> Enum.map(& &1.language)
+
+ language_options = Admin.Languages.disabling(already_assigned_langs)
+
+ socket =
+ socket
+ |> assign(:notification, notification)
+ |> assign(:language_options, language_options)
+ |> apply_action(socket.assigns.live_action, params)
+
+ # start with one empty input
+ |> assign(:recipients, [])
+
+ {:ok, socket}
+ end
+
+ def apply_action(socket, :new, params) do
+ localized_email = %LocalizedEmail{}
+
+ changeset =
+ Notifications.change_localized_email(
+ socket.assigns.current_scope,
+ socket.assigns.notification,
+ localized_email,
+ %{
+ "subject" => "",
+ "message" => "",
+ "button_text" => "",
+ "button_url" => "",
+ "language" => Map.get(params, "language", "")
+ }
+ )
+
+ preview_html = render_email_preview(changeset)
+
+ socket
+ |> assign(:page_title, "New Mailing")
+ |> assign(:localized_email, localized_email)
+ |> assign(:form, changeset)
+ |> assign(:preview_html, preview_html)
+ end
+
+ def apply_action(socket, :edit, %{"lang" => lang} = _params) do
+ localized_email =
+ Notifications.get_localized_email_by_lang!(
+ socket.assigns.current_scope,
+ socket.assigns.notification.id,
+ lang
+ )
+
+ changeset =
+ Notifications.change_localized_email(
+ socket.assigns.current_scope,
+ socket.assigns.notification,
+ localized_email,
+ %{}
+ )
+
+ preview_html = render_email_preview(changeset)
+
+ socket
+ |> assign(:page_title, "Edit Mailing")
+ |> assign(:localized_email, localized_email)
+ |> assign(:form, changeset)
+ |> assign(:preview_html, preview_html)
+ end
+
+ @impl true
+ def handle_event("validate", %{"localized_email" => params}, socket) do
+ changeset =
+ Notifications.change_localized_email(
+ socket.assigns.current_scope,
+ socket.assigns.notification,
+ %LocalizedEmail{},
+ params
+ )
+ |> Map.put(:action, :validate)
+
+ preview_html = render_email_preview(changeset)
+
+ {:noreply,
+ socket
+ |> assign(:form, changeset)
+ |> assign(:preview_html, preview_html)}
+ end
+
+ @impl true
+ def handle_event("submit", %{"localized_email" => localized_email_params}, socket) do
+ save_localized_email(socket, socket.assigns.live_action, localized_email_params)
+ end
+
+ defp save_localized_email(socket, :edit, localized_email_params) do
+ case Notifications.update_localized_email(
+ socket.assigns.current_scope,
+ socket.assigns.localized_email,
+ localized_email_params
+ ) do
+ {:ok, %LocalizedEmail{} = _localized_email} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Localized email updated successfully")
+ |> push_navigate(to: ~p"/admin/notifications/#{socket.assigns.notification}")}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ defp save_localized_email(socket, :new, localized_email_params) do
+ case Notifications.create_localized_email(
+ socket.assigns.current_scope,
+ socket.assigns.notification.id,
+ localized_email_params
+ ) do
+ {:ok, %LocalizedEmail{} = _localized_email} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Localized email created")
+ |> push_navigate(to: ~p"/admin/notifications/#{socket.assigns.notification}")}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply,
+ socket
+ |> assign(:form, changeset)}
+ end
+ end
+
+ defp render_email_preview(changeset) do
+ AdminWeb.EmailTemplates.render("call_to_action", %{
+ name: "
",
+ message: Ecto.Changeset.get_field(changeset, :message),
+ button_text: Ecto.Changeset.get_field(changeset, :button_text),
+ button_url: Ecto.Changeset.get_field(changeset, :button_url)
+ })
+ end
+end
diff --git a/lib/admin_web/live/notification_live/new.ex b/lib/admin_web/live/notification_live/new.ex
deleted file mode 100644
index 8a7e29490..000000000
--- a/lib/admin_web/live/notification_live/new.ex
+++ /dev/null
@@ -1,290 +0,0 @@
-defmodule AdminWeb.NotificationLive.New do
- use AdminWeb, :live_view
-
- alias Admin.Accounts
- alias Admin.Notifications
- alias Admin.Notifications.Notification
-
- @impl true
- def render(assigns) do
- ~H"""
-
- <.header>
- New Mailing
-
-
- <.form
- :let={form}
- for={@form}
- id="notification-form"
- phx-change="validate"
- phx-submit="submit"
- >
- <.input field={form[:title]} type="text" label="Title" />
- <.input field={form[:message]} type="textarea" label="Message" rows="6" />
-
-
-
- <%= if @recipient_method == "manual" do %>
-
-
-
-
- <%= for {email, idx} <- Enum.with_index(@manual_recipients) do %>
-
-
- <.button
- type="button"
- class="btn btn-soft btn-error"
- phx-click="manual_remove_row"
- phx-value-index={idx}
- >
- <.icon name="hero-trash" class="size-5" />
-
-
- <% end %>
-
-
- <.button type="button" class="btn btn-soft btn-secondary" phx-click="manual_add_row">
- <.icon name="hero-plus" class="size-5" /> Add email
-
-
- <%= if @form.errors[:recipients] do %>
-
- {elem(@form.errors[:recipients], 0)}
-
- <% end %>
-
- <% else %>
-
-
- <%= if @active_users == [] do %>
-
No active users found.
- <% else %>
-
-
- {length(@active_users)} active users (click to show)
-
-
- <%= for email <- @active_users do %>
- {email}
- <% end %>
-
-
- <% end %>
-
- <% end %>
-
-
-
-
- """
- end
-
- @impl true
- def mount(_params, _session, socket) do
- notification =
- Notifications.change_notification(socket.assigns.current_scope, %Notification{}, %{
- "title" => "",
- "message" => "",
- "recipients" => []
- })
-
- # UI state: recipient_method can be "manual" or "active_users"
- socket =
- socket
- |> assign(:page_title, "New Mailing")
- |> assign(:form, notification)
- |> assign(:recipient_method, "manual")
- # start with one empty input
- |> assign(:manual_recipients, [""])
- |> assign(:active_users, [])
- |> assign(:loading_active_users, false)
-
- {:ok, socket}
- end
-
- @impl true
- def handle_event("validate", %{"notification" => params}, socket) do
- # Merge recipients from UI state before validating
- {recipient_method, params} = ensure_recipients_from_ui(socket, params)
-
- changeset =
- Notifications.change_notification(socket.assigns.current_scope, %Notification{}, params)
- |> Map.put(:action, :validate)
-
- {:noreply,
- socket
- |> assign(:form, changeset)
- |> assign(:recipient_method, recipient_method)}
- end
-
- @impl true
- def handle_event("change_method", %{"recipient_method" => method}, socket) do
- method = method || "manual"
-
- case method do
- "manual" ->
- # Switch to manual; keep current manual state
- {:noreply, assign(socket, :recipient_method, "manual")}
-
- "active_users" ->
- # Fetch active users and set recipients to that list
- # You can do this async if Accounts.get_active_users/0 is slow.
- active =
- get_active_members()
- # take only email
- |> Enum.map(& &1.email)
-
- changeset =
- Notifications.update_recipients(
- socket.assigns.form,
- %{recipients: active}
- )
- |> Map.put(:action, :validate)
-
- {:noreply,
- socket
- |> assign(:recipient_method, "active_users")
- |> assign(:active_users, active)
- |> assign(:form, changeset)}
- end
- end
-
- @impl true
- def handle_event("manual_add_row", _params, socket) do
- {:noreply, update(socket, :manual_recipients, fn list -> list ++ [""] end)}
- end
-
- @impl true
- def handle_event("manual_remove_row", %{"index" => idx_str}, socket) do
- idx = parse_index(idx_str)
-
- updated =
- socket.assigns.manual_recipients
- |> Enum.with_index()
- |> Enum.reject(fn {_v, i} -> i == idx end)
- |> Enum.map(fn {v, _i} -> v end)
-
- {:noreply, assign(socket, :manual_recipients, updated)}
- end
-
- @impl true
- def handle_event("manual_update_row", params, socket) do
- [key | _] = Map.fetch!(params, "_target")
- value = params[key]
- "manual_email_" <> idx_str = key
- idx = parse_index(idx_str)
-
- updated =
- socket.assigns.manual_recipients
- |> Enum.with_index()
- |> Enum.map(fn {v, i} -> if i == idx, do: value, else: v end)
-
- changeset =
- Notifications.update_recipients(
- socket.assigns.form,
- %{recipients: updated}
- )
- |> Map.put(:action, :validate)
-
- {:noreply,
- socket
- |> assign(:manual_recipients, updated)
- |> assign(:form, changeset)}
- end
-
- @impl true
- def handle_event("submit", %{"notification" => params}, socket) do
- {recipient_method, params} = ensure_recipients_from_ui(socket, params)
-
- case Notifications.create_notification(socket.assigns.current_scope, params) do
- {:ok, %Notification{} = notif} ->
- Enum.each(
- notif.recipients,
- &(%{
- "member_email" => &1,
- "user_id" => socket.assigns.current_scope.user.id,
- "notification_id" => notif.id
- }
- |> Admin.MailerWorker.new()
- |> Oban.insert())
- )
-
- {:noreply,
- socket
- |> put_flash(:info, "Notification created")
- |> push_navigate(to: ~p"/admin/notifications")}
-
- {:error, %Ecto.Changeset{} = changeset} ->
- {:noreply,
- socket
- |> assign(:form, changeset)
- |> assign(:recipient_method, recipient_method)}
- end
- end
-
- defp ensure_recipients_from_ui(socket, params) do
- method = socket.assigns.recipient_method
-
- recipients =
- case method do
- "manual" ->
- socket.assigns.manual_recipients
- |> Enum.map(&String.trim/1)
- |> Enum.reject(&(&1 == ""))
-
- "active_users" ->
- socket.assigns.active_users
-
- _ ->
- []
- end
-
- {method, Map.put(params, "recipients", recipients)}
- end
-
- defp parse_index(idx) do
- case Integer.parse(to_string(idx)) do
- {n, _} -> n
- :error -> 0
- end
- end
-
- defp get_active_members do
- Accounts.get_active_members()
- rescue
- _ -> []
- end
-end
diff --git a/lib/admin_web/live/notification_live/show.ex b/lib/admin_web/live/notification_live/show.ex
index 209b56945..c776aeb1b 100644
--- a/lib/admin_web/live/notification_live/show.ex
+++ b/lib/admin_web/live/notification_live/show.ex
@@ -8,45 +8,251 @@ defmodule AdminWeb.NotificationLive.Show do
~H"""
<.header>
- Message: {@notification.title}
+ Composing message
<:actions>
<.button navigate={~p"/admin/notifications"}>
<.icon name="hero-arrow-left" />
+ <.button variant="primary" navigate={~p"/admin/notifications/#{@notification}/edit"}>
+ <.icon name="hero-pencil" /> Edit
+
<.list>
- <:item title="Title">{@notification.title}
- <:item title="Message">{@notification.message}
+ <:item title="Name">{@notification.name}
+ <:item title="Target Audience">{@notification.audience} {length(@recipients)}
+ <:item title="Default language">
+
+ {@notification.default_language}
+
+
- <%= if length(@notification.logs) > 0 do %>
- <.table id="notification_logs" rows={@notification.logs}>
- <:col :let={message_log} label="Email">{message_log.email}
- <:col :let={message_log} label="Sent at">{message_log.created_at}
- <:col :let={message_log} label="Status">{message_log.status}
-
+ Localized Emails
+
+
+
+
+ | Language |
+ Message |
+ Call to action |
+
+ {gettext("Actions")}
+ |
+
+
+
+ <%= for localized_email <- @notification.localized_emails do %>
+
+ |
+
+ {localized_email.language}
+
+ |
+
+
+ {localized_email.subject}
+ {localized_email.message}
+
+ |
+
+
+ {localized_email.button_text}
+ {localized_email.button_url}
+
+ |
+
+
+ <.link navigate={
+ ~p"/admin/notifications/#{@notification}/messages/#{localized_email.language}/edit"
+ }>
+ Edit
+
+ <.link
+ class="text-error"
+ phx-click={
+ JS.push("delete",
+ value: %{id: localized_email.id, language: localized_email.language}
+ )
+ |> hide("##{localized_email.id}")
+ }
+ >
+ Delete
+
+
+ |
+
+ <% end %>
+
+
+
+ <%= if @notification.localized_emails |> Enum.find(&(&1.language == @notification.default_language)) == nil do %>
+ <.alert severity="warning">
+
+ The default locale was not defined.
+
+ <:action>
+ <.button navigate={
+ ~p"/admin/notifications/#{@notification}/messages/new?language=#{@notification.default_language}"
+ }>
+ Add default locale
+
+
+
<% else %>
- No messages sent yet
+ <.button navigate={~p"/admin/notifications/#{@notification}/messages/new"}>
+ Add a localized message
+
+ <%= if length(@recipients) > 0 do %>
+
+ <.button variant="primary" phx-click="confirm_send_notification">
+ Send Notification
+
+
+ <% else %>
+ <.alert severity="warning">
+ The notification audience and language settings result in no recipients. Add localized messages to cover more languages or choose a larger audience.
+
+ <% end %>
<% end %>
+
+
"""
end
@impl true
- def mount(%{"id" => id}, _session, socket) do
+ def mount(%{"notification_id" => id}, _session, socket) do
if connected?(socket) do
- Notifications.subscribe_notifications(socket.assigns.current_scope)
+ Notifications.subscribe_notifications(socket.assigns.current_scope, id)
end
+ notification = Notifications.get_notification!(socket.assigns.current_scope, id)
+ included_langs = notification.localized_emails |> Enum.map(& &1.language)
+
+ {:ok, recipients} =
+ Notifications.get_target_audience(
+ socket.assigns.current_scope,
+ notification.audience,
+ if(notification.use_strict_languages, do: [only_langs: included_langs], else: [])
+ )
+
{:ok,
socket
|> assign(:page_title, "Show Mail")
|> assign(
:notification,
- Notifications.get_notification!(socket.assigns.current_scope, id)
- )}
+ notification
+ )
+ |> assign(:recipients, recipients)
+ |> assign(:show_modal, false)}
+ end
+
+ @impl true
+ def handle_event("toggle_strict_languages", _, socket) do
+ {:ok, notification} =
+ Notifications.toggle_strict_languages(
+ socket.assigns.current_scope,
+ socket.assigns.notification
+ )
+
+ included_langs = notification.localized_emails |> Enum.map(& &1.language)
+
+ {:ok, recipients} =
+ Notifications.get_target_audience(
+ socket.assigns.current_scope,
+ notification.audience,
+ if(notification.use_strict_languages, do: [only_langs: included_langs], else: [])
+ )
+
+ {:noreply, socket |> assign(:recipients, recipients)}
+ end
+
+ def handle_event("delete", %{"id" => id} = _params, socket) do
+ localized_email =
+ socket.assigns.notification.localized_emails
+ |> Enum.find(&(&1.id == id))
+
+ {:ok, _localized_email} =
+ Notifications.delete_localized_email(
+ socket.assigns.current_scope,
+ localized_email
+ )
+
+ {:noreply, socket}
+ end
+
+ def handle_event("cancel_send_notification", _, socket) do
+ {:noreply, assign(socket, :show_modal, false)}
+ end
+
+ def handle_event("confirm_send_notification", _params, socket) do
+ {:noreply, assign(socket, :show_modal, true)}
+ end
+
+ def handle_event("send_notification", _params, socket) do
+ # verify there is a local version for the default lang
+ case Notifications.get_localized_email_by_lang(
+ socket.assigns.current_scope,
+ socket.assigns.notification.id,
+ socket.assigns.notification.default_language
+ ) do
+ {:ok, _localized_email} ->
+ %{
+ user_id: socket.assigns.current_scope.user.id,
+ notification_id: socket.assigns.notification.id
+ }
+ |> Admin.MailingWorker.new()
+ |> Oban.insert()
+
+ {:ok, _notification} =
+ Notifications.update_sent_at(
+ socket.assigns.current_scope,
+ socket.assigns.notification
+ )
+
+ {:noreply,
+ socket
+ |> put_flash(:info, "Notification sent successfully")
+ |> push_navigate(to: ~p"/admin/notifications")}
+
+ {:error, :not_found} ->
+ {:noreply, socket |> put_flash(:error, "The default language is not available")}
+ end
end
@impl true
@@ -71,4 +277,17 @@ defmodule AdminWeb.NotificationLive.Show do
when type in [:created, :updated, :deleted] do
{:noreply, socket}
end
+
+ def handle_info(
+ {type, %Admin.Notifications.LocalizedEmail{} = _localized_email},
+ %{assigns: %{notification: %{id: id}}} = socket
+ )
+ when type in [:created, :updated, :deleted] do
+ {:noreply,
+ assign(
+ socket,
+ :notification,
+ Notifications.get_notification!(socket.assigns.current_scope, id)
+ )}
+ end
end
diff --git a/lib/admin_web/live/publisher_live/index.ex b/lib/admin_web/live/publisher_live/index.ex
index 780b3de40..410c3b771 100644
--- a/lib/admin_web/live/publisher_live/index.ex
+++ b/lib/admin_web/live/publisher_live/index.ex
@@ -19,7 +19,7 @@ defmodule AdminWeb.PublisherLive.Index do
<%= for {id, publisher} <- @streams.publishers do %>
JS.toggle_class("rotate-[-90deg]", to: "##{id}-wrapper .chevron", time: 300)
diff --git a/lib/admin_web/router.ex b/lib/admin_web/router.ex
index 8430b2f4e..4dc5e7e00 100644
--- a/lib/admin_web/router.ex
+++ b/lib/admin_web/router.ex
@@ -111,8 +111,16 @@ defmodule AdminWeb.Router do
scope "/notifications" do
live "/", NotificationLive.Index, :index
- live "/new", NotificationLive.New, :new
- live "/:id", NotificationLive.Show, :show
+ live "/new", NotificationLive.Form, :new
+
+ scope "/:notification_id" do
+ live "/", NotificationLive.Show, :show
+ live "/archive", NotificationLive.Show, :archive
+ live "/edit", NotificationLive.Form, :edit
+
+ live "/messages/new", NotificationMessageLive.Form, :new
+ live "/messages/:lang/edit", NotificationMessageLive.Form, :edit
+ end
end
end
diff --git a/priv/repo/migrations/20250806110912_create_users_auth_tables.exs b/priv/repo/migrations/20250806110912_create_users_auth_tables.exs
index 0608a9eb4..b48540ae7 100644
--- a/priv/repo/migrations/20250806110912_create_users_auth_tables.exs
+++ b/priv/repo/migrations/20250806110912_create_users_auth_tables.exs
@@ -34,6 +34,8 @@ defmodule Admin.Repo.Migrations.CreateUsersAuthTables do
add :name, :string
add :email, :string
add :type, :string
+ add :extra, :map
+ add :last_authenticated_at, :utc_datetime
timestamps(type: :utc_datetime)
end
diff --git a/priv/repo/migrations/20251209080411_localized_emails.exs b/priv/repo/migrations/20251209080411_localized_emails.exs
new file mode 100644
index 000000000..8e6674580
--- /dev/null
+++ b/priv/repo/migrations/20251209080411_localized_emails.exs
@@ -0,0 +1,41 @@
+defmodule Admin.Repo.Migrations.LocalizedEmails do
+ use Ecto.Migration
+
+ def change do
+ rename table(:notifications), :title, to: :name
+
+ alter table(:notifications) do
+ remove :message, :string
+
+ add :audience, :string
+ add :total_recipients, :integer, default: 0
+ add :default_language, :string, null: false, default: "en"
+ add :use_strict_languages, :boolean, null: false, default: false
+ add :sent_at, :utc_datetime
+ end
+
+ create index(:notifications, [:sent_at])
+
+ execute "Update notifications set audience = 'custom' where audience is null", ""
+
+ execute "Update notifications set total_recipients = ARRAY_LENGTH(recipients, 1) where total_recipients is null",
+ ""
+
+ alter table(:notifications) do
+ modify :audience, :string, null: false, from: {:string, null: true}
+ remove :recipients, {:array, :string}
+ end
+
+ create table(:localized_emails, primary_key: false) do
+ add :id, :uuid, primary_key: true
+ add :subject, :string, null: false
+ add :message, :string, null: false
+ add :button_text, :string
+ add :button_url, :string
+ add :language, :string, null: false
+ add :notification_id, references(:notifications, type: :uuid, on_delete: :delete_all)
+
+ timestamps(type: :utc_datetime)
+ end
+ end
+end
diff --git a/test/admin/mailer_worker_test.exs b/test/admin/mailer_worker_test.exs
deleted file mode 100644
index 7c1accb64..000000000
--- a/test/admin/mailer_worker_test.exs
+++ /dev/null
@@ -1,66 +0,0 @@
-defmodule Admin.MailerWorkerTest do
- use Admin.DataCase
- use Oban.Testing, repo: Admin.Repo
-
- import Admin.AccountsFixtures
- import Admin.NotificationsFixtures
-
- describe "mailer worker" do
- test "correct inputs" do
- scope = user_scope_fixture()
- member = member_fixture()
- notification = notification_fixture(scope)
-
- args = %{
- member_email: member.email,
- user_id: scope.user.id,
- notification_id: notification.id
- }
-
- Oban.Testing.with_testing_mode(:manual, fn ->
- assert {:ok, _} =
- args
- |> Admin.MailerWorker.new()
- |> Oban.insert()
-
- assert :ok = perform_job(Admin.MailerWorker, args)
- end)
- end
-
- test "invalid member email" do
- scope = user_scope_fixture()
- notification = notification_fixture(scope)
-
- args = %{
- member_email: "toto@email.com",
- user_id: scope.user.id,
- notification_id: notification.id
- }
-
- assert {:ok, _} =
- args
- |> Admin.MailerWorker.new()
- |> Oban.insert()
-
- assert {:cancel, :member_not_found} = perform_job(Admin.MailerWorker, args)
- end
-
- test "invalid notification id" do
- scope = user_scope_fixture()
- member = member_fixture()
-
- args = %{
- member_email: member.email,
- user_id: scope.user.id,
- notification_id: Ecto.UUID.generate()
- }
-
- assert {:ok, _} =
- args
- |> Admin.MailerWorker.new()
- |> Oban.insert()
-
- assert {:cancel, :notification_not_found} = perform_job(Admin.MailerWorker, args)
- end
- end
-end
diff --git a/test/admin/mailing_worker_test.exs b/test/admin/mailing_worker_test.exs
new file mode 100644
index 000000000..81f763ce5
--- /dev/null
+++ b/test/admin/mailing_worker_test.exs
@@ -0,0 +1,44 @@
+defmodule Admin.MailingWorkerTest do
+ use Admin.DataCase
+ use Oban.Testing, repo: Admin.Repo
+
+ import Admin.AccountsFixtures
+ import Admin.NotificationsFixtures
+
+ describe "mailing worker" do
+ test "correct inputs" do
+ scope = user_scope_fixture()
+ notification = notification_fixture(scope)
+
+ args = %{
+ user_id: scope.user.id,
+ notification_id: notification.id
+ }
+
+ Oban.Testing.with_testing_mode(:manual, fn ->
+ assert {:ok, _} =
+ args
+ |> Admin.MailingWorker.new()
+ |> Oban.insert()
+
+ assert :ok = perform_job(Admin.MailingWorker, args)
+ end)
+ end
+
+ test "invalid notification id" do
+ scope = user_scope_fixture()
+
+ args = %{
+ user_id: scope.user.id,
+ notification_id: Ecto.UUID.generate()
+ }
+
+ assert {:ok, _} =
+ args
+ |> Admin.MailingWorker.new()
+ |> Oban.insert()
+
+ assert {:cancel, :notification_not_found} = perform_job(Admin.MailingWorker, args)
+ end
+ end
+end
diff --git a/test/admin/notifications_test.exs b/test/admin/notifications_test.exs
index 6626b3385..66ab0b093 100644
--- a/test/admin/notifications_test.exs
+++ b/test/admin/notifications_test.exs
@@ -8,8 +8,12 @@ defmodule Admin.NotificationsTest do
import Admin.NotificationsFixtures
describe "notifications" do
- @empty_attrs %{message: nil, title: nil, recipients: nil}
- @invalid_email_attrs %{message: "A message", title: "title", recipients: ["test", "other"]}
+ @empty_attrs %{name: nil, audience: nil, default_language: nil}
+ @invalid_language_attrs %{
+ name: "A mailing message",
+ audience: "custom",
+ default_language: "invalid"
+ }
test "list_notifications/1 returns all notifications" do
scope = user_scope_fixture()
@@ -30,9 +34,9 @@ defmodule Admin.NotificationsTest do
test "create_notification/2 with valid data creates a notification" do
valid_attrs = %{
- message: "some message",
- title: "some subject",
- recipients: ["user1@example.com", "user2@example.com"]
+ name: "some message",
+ audience: "custom",
+ default_language: "en"
}
scope = user_scope_fixture()
@@ -40,9 +44,9 @@ defmodule Admin.NotificationsTest do
assert {:ok, %Notification{} = notification} =
Notifications.create_notification(scope, valid_attrs)
- assert notification.message == "some message"
- assert notification.title == "some subject"
- assert notification.recipients == ["user1@example.com", "user2@example.com"]
+ assert notification.name == "some message"
+ assert notification.audience == "custom"
+ assert notification.default_language == "en"
end
test "create_notification/2 with invalid data returns error changeset" do
@@ -52,19 +56,19 @@ defmodule Admin.NotificationsTest do
Notifications.create_notification(scope, @empty_attrs)
assert {:error, %Ecto.Changeset{}} =
- Notifications.create_notification(scope, @invalid_email_attrs)
+ Notifications.create_notification(scope, @invalid_language_attrs)
end
test "update_notification/3 with valid data updates the notification" do
scope = user_scope_fixture()
notification = notification_fixture(scope)
- update_attrs = %{message: "some updated message", title: "some updated subject"}
+ update_attrs = %{name: "some updated name", audience: "recent"}
assert {:ok, %Notification{} = notification} =
Notifications.update_notification(scope, notification, update_attrs)
- assert notification.message == "some updated message"
- assert notification.title == "some updated subject"
+ assert notification.name == "some updated name"
+ assert notification.audience == "recent"
end
test "update_notification/3 with invalid data returns error changeset" do
@@ -75,7 +79,7 @@ defmodule Admin.NotificationsTest do
Notifications.update_notification(scope, notification, @empty_attrs)
assert {:error, %Ecto.Changeset{}} =
- Notifications.update_notification(scope, notification, @invalid_email_attrs)
+ Notifications.update_notification(scope, notification, @invalid_language_attrs)
assert notification == Notifications.get_notification!(scope, notification.id)
end
diff --git a/test/admin_web/live/notification_live_test.exs b/test/admin_web/live/notification_live_test.exs
index acc36e4d5..156386474 100644
--- a/test/admin_web/live/notification_live_test.exs
+++ b/test/admin_web/live/notification_live_test.exs
@@ -4,8 +4,13 @@ defmodule AdminWeb.ServiceMessageLiveTest do
import Phoenix.LiveViewTest
import Admin.NotificationsFixtures
- @create_attrs %{title: "some title", message: "some message"}
- @invalid_attrs %{title: nil, message: nil}
+ @create_attrs %{
+ name: "some name",
+ audience: "active",
+ default_language: "en",
+ use_strict_languages: false
+ }
+ @invalid_attrs %{name: nil, audience: nil, default_language: nil}
setup :register_and_log_in_user
@@ -22,7 +27,7 @@ defmodule AdminWeb.ServiceMessageLiveTest do
{:ok, _index_live, html} = live(conn, ~p"/admin/notifications")
assert html =~ "Mailing"
- assert html =~ notification.title
+ assert html =~ notification.name
end
test "saves new notification", %{conn: conn} do
@@ -40,15 +45,6 @@ defmodule AdminWeb.ServiceMessageLiveTest do
|> form("#notification-form", notification: @invalid_attrs)
|> render_change() =~ "can't be blank"
- # The first dynamic email input uses the name "manual_email_0"
-
- form_live
- |> element("input[name=manual_email_0]")
- |> render_change(%{
- "_target" => ["manual_email_0"],
- "manual_email_0" => "alice@example.com"
- })
-
# After rows are set, validate the form fields
assert {:ok, index_live, _html} =
form_live
@@ -56,21 +52,21 @@ defmodule AdminWeb.ServiceMessageLiveTest do
notification: @create_attrs
)
|> render_submit()
- |> follow_redirect(conn, ~p"/admin/notifications")
+ |> follow_redirect(conn)
html = render(index_live)
- assert html =~ "Notification created"
- assert html =~ "some title"
+ assert html =~ "Composing message"
+ assert html =~ "some name"
end
test "deletes notification in listing", %{conn: conn, notification: notification} do
{:ok, index_live, _html} = live(conn, ~p"/admin/notifications")
+ # using the delete_sent event directly since we have a browser based confirmation that is hard to do in tests.
assert index_live
- |> element("#notifications-#{notification.id} a", "Delete")
- |> render_click()
+ |> render_change("delete_sent", %{"id" => notification.id})
- refute has_element?(index_live, "#notifications-#{notification.id}")
+ refute has_element?(index_live, "#sent_notifications-#{notification.id}")
end
end
@@ -81,7 +77,7 @@ defmodule AdminWeb.ServiceMessageLiveTest do
{:ok, _show_live, html} = live(conn, ~p"/admin/notifications/#{notification}")
assert html =~ "Show Mail"
- assert html =~ notification.title
+ assert html =~ notification.name
end
end
end
diff --git a/test/support/fixtures/notifications_fixtures.ex b/test/support/fixtures/notifications_fixtures.ex
index 48a4d9829..6cd40cdd6 100644
--- a/test/support/fixtures/notifications_fixtures.ex
+++ b/test/support/fixtures/notifications_fixtures.ex
@@ -10,9 +10,10 @@ defmodule Admin.NotificationsFixtures do
def notification_fixture(scope, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
- message: "some message",
- title: "some title",
- recipients: ["user1@example.com", "user2@example.com"]
+ name: "some name",
+ audience: "active",
+ default_language: "en",
+ use_strict_languages: false
})
{:ok, notification} = Admin.Notifications.create_notification(scope, attrs)