diff --git a/README.md b/README.md index a522bbdca..bf8eb5289 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ -# Graasp Phoenix +# Graasp Admin -This is the codebase for the Graasp platform written in -[Elixir](https://elixir-lang.org/) using -[the Phoenix web framework](https://phoenixframework.org) +This is the codebase for the Graasp admin platform written in [Elixir](https://elixir-lang.org/) using [the Phoenix web framework](https://phoenixframework.org). -This project was generated with phoenix version 1.8.0 +The admin platform enables administrators to: + +- manage publications +- send messages to target audiences +- manage interactive applications +- perform operational tasks (re-indexation, ...) +- explore analytics data ## Required tools -This project uses [mise](https://mise.jdx.dev/). +This project uses [mise](https://mise.jdx.dev/) for tasks and tool versions. +Install mise from [the instructions](https://mise.jdx.dev/getting-started.html) Install all dependencies with (installs `elixir` and `erlang`): @@ -16,6 +21,11 @@ Install all dependencies with (installs `elixir` and `erlang`): mise i ``` +As of writing this, the following versions are used: + +- elixir: 1.19.4 +- erlang: 28 (OTP 28) +
Installing Elixir with brew (not recommended) @@ -43,30 +53,44 @@ brew install elixir-ls ### PostgreSQL -You will need a running PostgreSQL server. +The admin platform uses the same database as the core platform. + +We recommend that you use the database provided by the devcontainer for the graasp/core project when running the admin. + +For tests and in case you would only need access to things that the admin does (nothing related to graasp) you would be fine using a local postgres instance for example via a docker container, or with an app such as [Postgres.app](https://postgresapp.com/) on MacOS. + +#### Devcontainer database -This project is made to work with the postgresql server running in the devcontainer for the graasp/core project. +You want to use all features of graasp and have an install of the core project running in the devcotnainer. +In this case, you should have a the user `graasper` owning the `graasp` database accessible on `localhost:5432` from the host machine. +Ensure this postgres is running when running the admin. -In case you do not want to use the devcontainer, you can use the following command to start a postgresql server: +You should ensure that the migrations from the core project are applied. After that run the admin-specific migrations with: `mix ecto.migrate`. + +#### Local database docker + +In case you do not want to use the devcontainer, you can use the following command to start a postgresql server in a docker container. ```sh docker run -d -p 5432:5432 \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_DB=postgres \ - -e POSTGRES_PASSWORD="postgres" \ + -e POSTGRES_USER=graasper \ + -e POSTGRES_DB=graasp \ + -e POSTGRES_PASSWORD="graasper" \ --name postgres postgres:17.5-alpine ``` -With a graphical client like [Postgres.app](https://postgresapp.com/) on MacOS. +#### Local database (gui client) + +You can also use a graphical client like [Postgres.app](https://postgresapp.com/) on MacOS. ## Getting Started 0. Ensure you have Elixir installed (`elixir -v` should show you the version) -1. Install project dependencies with: `mix setup` -2. Start you Phoenix server: - - Run `mix setup` to install and setup dependencies - - Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` -3. Create a `.env.sh` file with the following content. Use the values you get from configuring garage in the core project: +1. Install project dependencies with: `mix deps.get` +2. Compile the project with: `mix compile` +3. Run the migrations with: `mix ecto.migrate` +4. Create a env.sh +5. Create a `.env.sh` file with the following content. Use the values you get from configuring garage in the core project: ```sh # .env.sh export AWS_ACCESS_KEY_ID=GK3b... @@ -77,8 +101,16 @@ With a graphical client like [Postgres.app](https://postgresapp.com/) on MacOS. ```sh source .env.sh ``` +6. Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. +## Testing + +For tests you should have a database available on `localhost:5433` with user `postgres` and password `postgres`. +To run the tests: `mix test` +After some failed tests re-run only failed tests with: `mix test --failed` +To debug failed tests: `iex -S mix test --failed --breakpoints --trace` + ## Deployment The application is deployed using ECS. The deployment process is handled by the graasp/infrastrucutre repository. 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 80290bafb..360b14d70 100644 --- a/lib/admin/accounts.ex +++ b/lib/admin/accounts.ex @@ -179,6 +179,16 @@ defmodule Admin.Accounts do |> update_user_and_delete_all_tokens() end + def change_user_name(user, attrs \\ %{}) do + User.name_changeset(user, attrs) + end + + def update_user_name(user, attrs \\ %{}) do + user + |> User.name_changeset(attrs) + |> Repo.update() + end + ## Session @doc """ @@ -369,6 +379,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" @@ -376,6 +387,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..2840db31b 100644 --- a/lib/admin/accounts/account.ex +++ b/lib/admin/accounts/account.ex @@ -9,6 +9,7 @@ defmodule Admin.Accounts.Account do field :name, :string field :email, :string field :type, :string + field :extra, :map 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.ex b/lib/admin/accounts/user.ex index 58cb88531..eea239db8 100644 --- a/lib/admin/accounts/user.ex +++ b/lib/admin/accounts/user.ex @@ -10,6 +10,8 @@ defmodule Admin.Accounts.User do # when we unify the access control for all users and use user roles to define who is an admin or not. schema "admins" do field :email, :string + field :name, :string + field :language, :string field :password, :string, virtual: true, redact: true field :hashed_password, :string, redact: true field :confirmed_at, :utc_datetime @@ -138,4 +140,16 @@ defmodule Admin.Accounts.User do Bcrypt.no_user_verify() false end + + def name_changeset(user, attrs) do + user + |> cast(attrs, [:name]) + |> validate_required([:name]) + end + + def language_changeset(user, attrs) do + user + |> cast(attrs, [:language]) + |> validate_required([:language]) + end end 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/languages.ex b/lib/admin/languages.ex new file mode 100644 index 000000000..a8698559c --- /dev/null +++ b/lib/admin/languages.ex @@ -0,0 +1,57 @@ +defmodule Admin.Languages do + @moduledoc """ + This module handles the currently supported languages. + + It allows to get a language list suitable for displaying a select input with some options disabled. + """ + @languages [ + %{value: "en", key: "English"}, + %{value: "fr", key: "French"}, + %{value: "es", key: "Spanish"}, + %{value: "de", key: "German"}, + %{value: "it", key: "Italian"} + ] + + def all do + @languages + end + + def all_options do + @languages |> Enum.map(&Keyword.new(&1)) + end + + def all_values do + @languages |> Enum.map(& &1.value) + end + + @doc """ + Returns a list of languages excluding the ones with the given codes. + + ## Examples + iex> Admin.Languages.excluding(["en", "fr"]) + [%{value: "es", key: "Spanish"}, %{value: "de", key: "German"}, %{value: "it", key: "Italian"}] + """ + def excluding(language_codes) when is_list(language_codes) do + @languages |> Enum.reject(&(&1.value in language_codes)) + end + + @doc """ + Returns a list of keyword lists with languages with the disabled languages. Can be used in select options. + + ## Examples + iex> Admin.Languages.disabling(["en", "fr"]) + [ + [value: "en", key: "English", disabled: true], + [value: "fr", key: "French", disabled: true], + [value: "es", key: "Spanish", disabled: false], + [value: "de", key: "German", disabled: false], + [value: "it", key: "Italian", disabled: false] + ] + """ + def disabling(language_codes) when is_list(language_codes) do + @languages + |> Enum.map(fn %{value: value, key: key} -> + Keyword.new(value: value, key: key, disabled: value in language_codes) + end) + end +end 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..effeb734e 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,30 @@ 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 + with {1, notification} <- + from(n in Notification, + where: n.id == ^notification.id, + select: n, + update: [set: [sent_at: fragment("NOW()")]] + ) + |> Repo.update_all([]) do + {:ok, notification} + else + {: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 +74,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 +100,19 @@ 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]) + 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 +130,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 +146,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 +174,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 +213,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..1163a3edd 100644 --- a/lib/admin/notifications/notification.ex +++ b/lib/admin/notifications/notification.ex @@ -7,95 +7,109 @@ 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 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, :total_recipients]) + |> validate_required([:name, :audience, :default_language]) + |> validate_inclusion(:default_language, ["en", "fr", "es", "it", "de"]) 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) + |> cast(attrs, [:total_recipients]) + |> validate_required([:total_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() + 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 - 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 set_sent_at(notification) do + notification + |> change(%{}) + |> put_change(:sent_at, DateTime.utc_now()) 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) - 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() + # 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 + # 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) + # 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 a12fe74db..81068e524 100644 --- a/lib/admin_web/controllers/admin_html/dashboard.html.heex +++ b/lib/admin_web/controllers/admin_html/dashboard.html.heex @@ -1,6 +1,6 @@ <.header> - Welcome, {@current_scope.user.email} + Welcome, {@current_scope.user.name || @current_scope.user.email}
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..b70316391 --- /dev/null +++ b/lib/admin_web/live/notification_live/form.ex @@ -0,0 +1,186 @@ +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) +
+ +
+ <% end %> +
+ <% end %> + + <.input + field={form[:default_language]} + type="select" + prompt="Select the default language" + label="Default Language" + options={Admin.Languages.all_options()} + /> + +
+ <.button variant="primary">Save Mail + <.button navigate={~p"/notifications/#{@notification}"}>Cancel +
+ +
+ """ + 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" => "" + }) + + socket + |> assign(:page_title, "New Mail") + |> assign(:notification, notification) + |> assign(:form, changeset) + |> assign(:recipients, []) + 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) + 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"/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"/notifications/#{notif}")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, + socket + |> assign(:form, changeset)} + end + end +end diff --git a/lib/admin_web/live/notification_live/index.ex b/lib/admin_web/live/notification_live/index.ex index 021f3f0ff..ab28d75ad 100644 --- a/lib/admin_web/live/notification_live/index.ex +++ b/lib/admin_web/live/notification_live/index.ex @@ -15,18 +15,62 @@ defmodule AdminWeb.NotificationLive.Index do - <%!-- Idea: represent the mails as cards ? --%> + <%= if @sending_status do %> + + <% end %> + +

Drafts

<.table - id="notifications" - rows={@streams.notifications} + id="wip_notifications" + rows={@streams.wip_notifications} row_click={fn {_id, notification} -> JS.navigate(~p"/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"/notifications/#{notification}"}>Show +
+ + <:action :let={{id, notification}}> + <.link + class="text-error" + phx-click={JS.push("delete", 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"/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"/notifications/#{notification}"}>Show @@ -50,12 +94,31 @@ 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) + |> assign( + :sent_notifications, + Notifications.list_recently_sent_notifications(socket.assigns.current_scope) + ) + |> stream( + :wip_notifications, + wip_notifications + ) + |> stream( + :sent_notifications, + sent_notifications + )} end @impl true @@ -77,4 +140,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..a8be6265e --- /dev/null +++ b/lib/admin_web/live/notification_live/message_live/form.ex @@ -0,0 +1,194 @@ +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" /> + +
+ <.button variant="primary">Save Mail + <.button type="button" navigate={~p"/notifications/#{@notification}"}> + Cancel + +
+ + +
+ Preview +