diff --git a/.formatter.exs b/.formatter.exs index ef8840ce6..2f55c0e2c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,5 @@ [ - import_deps: [:ecto, :ecto_sql, :phoenix], + import_deps: [:oban, :ecto, :ecto_sql, :phoenix], subdirectories: ["priv/*/migrations"], plugins: [Phoenix.LiveView.HTMLFormatter], inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] diff --git a/config/config.exs b/config/config.exs index b8c2b95f6..257a387f2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,6 +7,18 @@ # General application configuration import Config +config :admin, Oban, + plugins: [ + # retain jobs for 7 days + {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7}, + # rescue orphan jobs after 2h + {Oban.Plugins.Lifeline, rescue_after: :timer.hours(2)} + ], + engine: Oban.Engines.Basic, + notifier: Oban.Notifiers.Postgres, + queues: [default: 10, mailers: 1], + repo: Admin.Repo + config :admin, :scopes, user: [ default: true, diff --git a/config/test.exs b/config/test.exs index 1106c33d1..ab1bb202d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,4 +1,5 @@ import Config +config :admin, Oban, testing: :manual # Only in tests, remove the complexity from the password hashing algorithm config :bcrypt_elixir, :log_rounds, 1 diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex index d9729b4e3..d100a002e 100644 --- a/lib/admin/accounts.ex +++ b/lib/admin/accounts.ex @@ -4,6 +4,7 @@ defmodule Admin.Accounts do """ require Logger import Ecto.Query, warn: false + alias Admin.Accounts.Account alias Admin.Repo alias Admin.Accounts.{User, UserNotifier, UserToken} @@ -352,4 +353,33 @@ defmodule Admin.Accounts do confirmed: Admin.Repo.aggregate(from(u in User, where: not is_nil(u.confirmed_at)), :count) } end + + # Graasp Members + + def get_member!(id) do + Repo.get!(Account, id) + end + + def get_member_by_email(email) do + case Repo.get_by(Account, email: email) do + %Account{} = user -> {:ok, user} + nil -> {:error, :member_not_found} + end + end + + def get_active_members do + Repo.all( + from(m in Account, + where: + not is_nil(m.last_authenticated_at) and m.last_authenticated_at > ago(90, "day") and + m.type == "individual" + ) + ) + end + + def create_member(attrs \\ %{}) do + %Account{} + |> Account.changeset(attrs) + |> Repo.insert() + end end diff --git a/lib/admin/accounts/account.ex b/lib/admin/accounts/account.ex index b52e18beb..000208284 100644 --- a/lib/admin/accounts/account.ex +++ b/lib/admin/accounts/account.ex @@ -1,8 +1,9 @@ defmodule Admin.Accounts.Account do @moduledoc """ - This represents a graasp user. + This represents a graasp user. """ use Admin.Schema + import Ecto.Changeset schema "account" do field :name, :string @@ -11,4 +12,20 @@ defmodule Admin.Accounts.Account do timestamps(type: :utc_datetime) end + + @doc false + def changeset(account, attrs) do + account + |> cast(attrs, [:name, :email, :type]) + |> validate_required([:name, :email, :type]) + |> validate_email() + end + + defp validate_email(changeset) do + changeset + |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, + message: "must have the @ sign and no spaces" + ) + |> validate_length(:email, max: 160) + end end diff --git a/lib/admin/accounts/user_notifier.ex b/lib/admin/accounts/user_notifier.ex index 13c6ea096..71c4c42f5 100644 --- a/lib/admin/accounts/user_notifier.ex +++ b/lib/admin/accounts/user_notifier.ex @@ -7,9 +7,31 @@ defmodule Admin.Accounts.UserNotifier do alias Admin.Accounts.Account alias Admin.Accounts.User alias Admin.Mailer + alias Admin.Notifications.MailingTemplates @footer "Graasp.org is a learning experience platform." + def test_email(scope) do + html = + MailingTemplates.simple_call_to_action(%{ + user: scope, + message: "This is a test email.", + button_text: "Click here", + button_url: "https://example.com" + }) + + email = + new() + |> to("basile@graasp.org") + |> from({"Admin", "admin@graasp.org"}) + |> subject("test email") + |> html_body(html) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + # Delivers the email using the application mailer. defp deliver(recipient, subject, body) do email = @@ -24,6 +46,20 @@ defmodule Admin.Accounts.UserNotifier do end end + def deliver_notification(user, subject, message_text) do + deliver(user.email, subject, """ + + ============================== + + Hi #{user.name}, + + #{message_text} + + ============================== + #{@footer} + """) + end + @doc """ Deliver publication removal information. """ diff --git a/lib/admin/application.ex b/lib/admin/application.ex index 28b7e470e..a6327c21f 100644 --- a/lib/admin/application.ex +++ b/lib/admin/application.ex @@ -19,6 +19,7 @@ defmodule Admin.Application do AdminWeb.Telemetry, Admin.Repo, {DNSCluster, query: Application.get_env(:admin, :dns_cluster_query) || :ignore}, + {Oban, Application.fetch_env!(:admin, Oban)}, {Phoenix.PubSub, name: Admin.PubSub}, # Start a worker by calling: Admin.Worker.start_link(arg) # {Admin.Worker, arg}, diff --git a/lib/admin/file_size.ex b/lib/admin/file_size.ex index 8d17e22a4..0219c045d 100644 --- a/lib/admin/file_size.ex +++ b/lib/admin/file_size.ex @@ -37,11 +37,6 @@ defmodule Admin.Utils.FileSize do "#{format_number(value, 2)} #{unit}" end - # Formats numbers with fixed precision, trimming trailing zeros neatly. - defp format_number(number, precision) when precision <= 0 do - Integer.to_string(trunc(number)) - end - defp format_number(number, precision) do formatted = number diff --git a/lib/admin/mailer_worker.ex b/lib/admin/mailer_worker.ex new file mode 100644 index 000000000..b42e6a35e --- /dev/null +++ b/lib/admin/mailer_worker.ex @@ -0,0 +1,62 @@ +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, reason} when reason in [:member_not_found, :notification_not_found] -> + Notifications.save_log( + scope, + %{ + email: member_email, + status: "failed" + }, + %Notification{id: notification_id} + ) + + {:cancel, reason} + + {:error, _} -> + {:error, "Failed to send notification"} + end + end +end diff --git a/lib/admin/notifications.ex b/lib/admin/notifications.ex new file mode 100644 index 000000000..e47e6fc60 --- /dev/null +++ b/lib/admin/notifications.ex @@ -0,0 +1,159 @@ +defmodule Admin.Notifications do + @moduledoc """ + The Notifications context. + """ + + import Ecto.Query, warn: false + alias Admin.Repo + + alias Admin.Accounts.Scope + alias Admin.Notifications.Log + alias Admin.Notifications.Notification + + # Notifications + def new_notification, do: %Notification{} + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking notification changes. + + ## Examples + + iex> change_notification(scope, notification) + %Ecto.Changeset{data: %Notification{}} + + """ + def change_notification(%Scope{} = scope, %Notification{} = notification, attrs \\ %{}) do + Notification.changeset(notification, attrs, scope) + end + + def update_recipients(%Ecto.Changeset{} = notification, %{recipients: _} = attrs) do + Notification.update_recipients(notification, attrs) + 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])} + end + end + + @doc """ + Subscribes to scoped notifications about any notification changes. + + The broadcasted messages match the pattern: + + * {:created, %Notification{}} + * {:updated, %Notification{}} + * {:deleted, %Notification{}} + + """ + def subscribe_notifications(%Scope{} = _scope) do + Phoenix.PubSub.subscribe(Admin.PubSub, "notifications") + end + + defp broadcast_notification(%Scope{} = _scope, message) do + Phoenix.PubSub.broadcast(Admin.PubSub, "notifications", message) + end + + @doc """ + Returns the list of notifications. + + ## Examples + + iex> list_notifications(scope) + [%Notification{}, ...] + + """ + def list_notifications(%Scope{} = _scope) do + Repo.all(Notification) |> Repo.preload([:logs]) + end + + @doc """ + Gets a single notification. + + Raises `Ecto.NoResultsError` if the Notification does not exist. + + ## Examples + + iex> get_notification!(scope, 123) + %Notification{} + + iex> get_notification!(scope, 456) + ** (Ecto.NoResultsError) + + """ + def get_notification!(%Scope{} = _scope, id) do + Repo.get_by!(Notification, id: id) |> Repo.preload(:logs) + end + + @doc """ + Gets a single notification. + + ## Examples + + iex> get_notification(scope, 123) + {:ok, %Notification{}} + + iex> get_notification(scope, 456) + {:error, :not_found} + + """ + def get_notification(%Scope{} = _scope, id) do + case Repo.get_by(Notification, id: id) |> Repo.preload(:logs) do + %Notification{} = notification -> {:ok, notification} + nil -> {:error, :notification_not_found} + end + end + + @doc """ + Updates a service_message. + + ## Examples + + iex> update_service_message(scope, service_message, %{field: new_value}) + {:ok, %ServiceMessage{}} + + iex> update_service_message(scope, service_message, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_notification(%Scope{} = scope, %Notification{} = notification, attrs) do + with {:ok, notification = %Notification{}} <- + notification + |> Notification.changeset(attrs, scope) + |> Repo.update() do + broadcast_notification(scope, {:updated, notification}) + {:ok, notification} + end + end + + @doc """ + Deletes a notification. + + ## Examples + + iex> delete_notification(scope, notification) + {:ok, %Notification{}} + + iex> delete_notification(scope, notification) + {:error, %Ecto.Changeset{}} + + """ + def delete_notification(%Scope{} = scope, %Notification{} = notification) do + with {:ok, notification = %Notification{}} <- + Repo.delete(notification) do + broadcast_notification(scope, {:deleted, notification}) + {:ok, notification} + end + end + + def save_log(%Scope{} = scope, log, %Notification{id: notification_id} = notification) do + %Log{} + |> Log.changeset(log, notification_id) + |> Repo.insert() + + broadcast_notification(scope, {:updated, notification}) + end +end diff --git a/lib/admin/notifications/log.ex b/lib/admin/notifications/log.ex new file mode 100644 index 000000000..9ab39d2af --- /dev/null +++ b/lib/admin/notifications/log.ex @@ -0,0 +1,26 @@ +defmodule Admin.Notifications.Log do + @moduledoc """ + Schema for storing notification logs. + """ + use Admin.Schema + import Ecto.Changeset + + @statuses ~w(sent failed)a + + schema "notification_logs" do + field :email, :string + field :status, :string + + belongs_to :notification, Admin.Notifications.Notification + + timestamps(type: :utc_datetime, updated_at: false) + end + + def changeset(message_log, attrs, notification_id) do + message_log + |> cast(attrs, [:email, :status]) + |> validate_required([:email, :status]) + |> validate_inclusion(:status, @statuses) + |> put_change(:notification_id, notification_id) + end +end diff --git a/lib/admin/notifications/mailing_templates.ex b/lib/admin/notifications/mailing_templates.ex new file mode 100644 index 000000000..fb5f3cd6a --- /dev/null +++ b/lib/admin/notifications/mailing_templates.ex @@ -0,0 +1,73 @@ +defmodule Admin.Notifications.MailingTemplates do + @moduledoc """ + Module for managing mailing templates. + """ + + use Phoenix.Component + import Phoenix.Template, only: [render_to_string: 4] + + def call_to_action_email(assigns) do + ~H""" + + + + + + + + + + + + + + Graasp + + + + + + + + Hello {@user.name}, + + + {@message} + + + {@button_text} + + + In case you can not click the button above here is the link: + + + {@button_url} + + + + + + + + You are receiving this email because of your account on Graasp. + + + Graasp Association, Valais, Switzerland + + + + + + """ + end + + def simple_call_to_action(assigns) do + {:ok, html} = + render_to_string(__MODULE__, "call_to_action_email", "html", assigns) |> Mjml.to_html() + + html + end +end diff --git a/lib/admin/notifications/notification.ex b/lib/admin/notifications/notification.ex new file mode 100644 index 000000000..d8a783f11 --- /dev/null +++ b/lib/admin/notifications/notification.ex @@ -0,0 +1,101 @@ +defmodule Admin.Notifications.Notification do + @moduledoc """ + Schema for storing notifications. + """ + + use Admin.Schema + import Ecto.Changeset + + schema "notifications" do + field :title, :string + field :message, :string + field :recipients, {:array, :string} + + has_many :logs, Admin.Notifications.Log + 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) + end + + def update_recipients(notification, %{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() + 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/layouts.ex b/lib/admin_web/components/layouts.ex index f001916e0..5fe6998ff 100644 --- a/lib/admin_web/components/layouts.ex +++ b/lib/admin_web/components/layouts.ex @@ -169,7 +169,9 @@ defmodule AdminWeb.Layouts do
  • <.link navigate={~p"/publishers"}>Apps
  • +
  • <.link navigate={~p"/notifications"}>Mailing
  • <.link navigate={~p"/users/settings"}>Settings
  • +
  • <.link navigate={~p"/oban"}>Oban
  • <%= if @current_scope do %> @@ -203,7 +205,9 @@ defmodule AdminWeb.Layouts do
  • <.link navigate={~p"/publishers"}>Apps
  • +
  • <.link navigate={~p"/notifications"}>Mailing
  • <.link navigate={~p"/users/settings"}>Settings
  • +
  • <.link navigate={~p"/oban"}>Oban
  • <% end %>
    diff --git a/lib/admin_web/controllers/planned_maintenance_html/index.html.heex b/lib/admin_web/controllers/planned_maintenance_html/index.html.heex index 812508441..07b733e5a 100644 --- a/lib/admin_web/controllers/planned_maintenance_html/index.html.heex +++ b/lib/admin_web/controllers/planned_maintenance_html/index.html.heex @@ -1,4 +1,4 @@ - + <.header> Listing Maintenances <:actions> diff --git a/lib/admin_web/controllers/published_item_controller.ex b/lib/admin_web/controllers/published_item_controller.ex index f8d313174..714c2c412 100644 --- a/lib/admin_web/controllers/published_item_controller.ex +++ b/lib/admin_web/controllers/published_item_controller.ex @@ -11,6 +11,7 @@ defmodule AdminWeb.PublishedItemController do published_items = Publications.list_published_items(100) render(conn, :index, + page_title: "Published Items", published_items: published_items, changeset: PublishedItemSearchForm.changeset(%PublishedItemSearchForm{}, %{}) ) diff --git a/lib/admin_web/live/notification_live/index.ex b/lib/admin_web/live/notification_live/index.ex new file mode 100644 index 000000000..021f3f0ff --- /dev/null +++ b/lib/admin_web/live/notification_live/index.ex @@ -0,0 +1,80 @@ +defmodule AdminWeb.NotificationLive.Index do + use AdminWeb, :live_view + alias Admin.Notifications + + @impl true + def render(assigns) do + ~H""" + + <.header> + Mailing + <:actions> + <.button variant="primary" navigate={~p"/notifications/new"}> + <.icon name="hero-plus" /> New Mail + + + + + <%!-- Idea: represent the mails as cards ? --%> + <.table + id="notifications" + rows={@streams.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="Sent">{length(notification.logs)} + <: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 + + + +
    + """ + end + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Notifications.subscribe_notifications(socket.assigns.current_scope) + end + + {:ok, + socket + |> assign(:page_title, "Mailing") + |> stream(:notifications, Notifications.list_notifications(socket.assigns.current_scope))} + 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) + + {:noreply, stream_delete(socket, :notifications, notification)} + end + + @impl true + def handle_info({type, %Admin.Notifications.Notification{}}, socket) + when type in [:created, :updated, :deleted] do + {:noreply, + stream( + socket, + :notifications, + Notifications.list_notifications(socket.assigns.current_scope), + reset: true + )} + end +end diff --git a/lib/admin_web/live/notification_live/new.ex b/lib/admin_web/live/notification_live/new.ex new file mode 100644 index 000000000..80bdb502e --- /dev/null +++ b/lib/admin_web/live/notification_live/new.ex @@ -0,0 +1,290 @@ +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" /> + +
    + Recipients + + + Choose how to populate recipient emails. + +
    + + <%= 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) +
    + +
    + <% end %> +
    + <% end %> + +
    + <.button variant="primary">Create Mail +
    + +
    + """ + 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"/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 new file mode 100644 index 000000000..102cce3b6 --- /dev/null +++ b/lib/admin_web/live/notification_live/show.ex @@ -0,0 +1,74 @@ +defmodule AdminWeb.NotificationLive.Show do + use AdminWeb, :live_view + + alias Admin.Notifications + + @impl true + def render(assigns) do + ~H""" + + <.header> + Message: {@notification.title} + <:actions> + <.button navigate={~p"/notifications"}> + <.icon name="hero-arrow-left" /> + + + + + <.list> + <:item title="Title">{@notification.title} + <:item title="Message">{@notification.message} + + + <%= 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} + + <% else %> +

    No messages sent yet

    + <% end %> +
    + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + if connected?(socket) do + Notifications.subscribe_notifications(socket.assigns.current_scope) + end + + {:ok, + socket + |> assign(:page_title, "Show Mail") + |> assign( + :notification, + Notifications.get_notification!(socket.assigns.current_scope, id) + )} + end + + @impl true + def handle_info( + {:updated, %Admin.Notifications.Notification{id: id} = notification}, + %{assigns: %{notification: %{id: id}}} = socket + ) do + {:noreply, assign(socket, :notification, notification)} + end + + def handle_info( + {:deleted, %Admin.Notifications.Notification{id: id}}, + %{assigns: %{notification: %{id: id}}} = socket + ) do + {:noreply, + socket + |> put_flash(:error, "The current notification was deleted.") + |> push_navigate(to: ~p"/notifications")} + end + + def handle_info({type, %Admin.Notifications.Notification{}}, socket) + when type in [:created, :updated, :deleted] do + {:noreply, socket} + end +end diff --git a/lib/admin_web/live/user_live/listing.ex b/lib/admin_web/live/user_live/listing.ex index 293aac470..0f140e45f 100644 --- a/lib/admin_web/live/user_live/listing.ex +++ b/lib/admin_web/live/user_live/listing.ex @@ -72,6 +72,7 @@ defmodule AdminWeb.UserLive.Listing do socket = socket + |> assign(:page_title, "Users") |> stream(:users, Accounts.list_users()) |> assign(:show_modal, false) |> assign(:user_to_delete, nil) diff --git a/lib/admin_web/router.ex b/lib/admin_web/router.ex index f37dd35a4..6bb87cefa 100644 --- a/lib/admin_web/router.ex +++ b/lib/admin_web/router.ex @@ -1,6 +1,7 @@ defmodule AdminWeb.Router do use AdminWeb, :router + import Oban.Web.Router import AdminWeb.UserAuth pipeline :browser do @@ -96,6 +97,12 @@ defmodule AdminWeb.Router do end end end + + scope "/notifications" do + live "/", NotificationLive.Index, :index + live "/new", NotificationLive.New, :new + live "/:id", NotificationLive.Show, :show + end end post "/users/update-password", UserSessionController, :update_password @@ -128,5 +135,8 @@ defmodule AdminWeb.Router do get "/published_items/featured", PublishedItemController, :featured resources "/published_items", PublishedItemController, except: [:update, :delete, :edit] post "/published_items/search", PublishedItemController, :search + + # oban dashboard for jobs + oban_dashboard("/oban") end end diff --git a/lib/mix/tasks/webapp.ex b/lib/mix/tasks/webapp.ex deleted file mode 100644 index 6eaf5ee88..000000000 --- a/lib/mix/tasks/webapp.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Mix.Tasks.Webapp do - @moduledoc """ - React frontend compilation and bundling for production. - """ - use Mix.Task - require Logger - # Path for the frontend static assets that are being served - # from our Phoenix router when accessing /app/* for the first time - @public_path "./priv/static/webapp" - - @shortdoc "Compile and bundle React frontend for production" - def run(_) do - Logger.info("📦 - Installing NPM packages") - System.cmd("pnpm", ["install", "--quiet"], cd: "./frontend") - - Logger.info("⚙️ - Compiling React frontend") - System.cmd("pnpm", ["run", "build"], cd: "./frontend") - - Logger.info("🚛 - Moving dist folder to Phoenix at #{@public_path}") - # First clean up any stale files from previous builds if any - System.cmd("rm", ["-rf", @public_path]) - System.cmd("cp", ["-R", "./frontend/dist", @public_path]) - - Logger.info("⚛️ - React frontend ready.") - end -end diff --git a/mix.exs b/mix.exs index c10b61267..29e54d671 100644 --- a/mix.exs +++ b/mix.exs @@ -111,7 +111,13 @@ defmodule Admin.MixProject do {:poison, "~> 6.0"}, {:hackney, "~> 1.9"}, # optional dependency to parse XML - {:sweet_xml, "~> 0.7"} + {:sweet_xml, "~> 0.7"}, + # jobs with Oban + {:oban, "~> 2.19"}, + {:oban_web, "~> 2.11"}, + + # html templating for emails + {:mjml, "~> 5.0"} ] end diff --git a/mix.lock b/mix.lock index a3c41659e..51683208d 100644 --- a/mix.lock +++ b/mix.lock @@ -3,6 +3,7 @@ "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.16", "8a4f9a7c8b81cda88231a08fe69e3254f16833053b23fa63274b05cbc61d2a1e", [:mix], [], "hexpm", "33689203a0eaaf02fcd0e86eadfbcf1bd636100455350592e7e2628564022aaf"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, @@ -27,6 +28,7 @@ "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, @@ -35,17 +37,22 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.3", "67b3d9fa8691b727317e0cc96b9b3093be00ee45419ffb221cdeee88e75d1360", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "87748d3c4afe949c7c6eb7150c958c2bcba43fc5b2a02686af30e636b74bccb7"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mjml": {:hex, :mjml, "5.2.0", "f0ef9ae7028948fb4a9259a4d9f0fc50f6c16864694d60f3f715724fdaa5c0ba", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8.1", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "bf39d2e0041f1f08afd07694239be39a8c173b00649e3463c2bd959473092c2a"}, "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oban": {:hex, :oban, "2.20.1", "39d0b68787e5cf251541c0d657a698f6142a24d8744e1e40b2cf045d4fa232a6", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17a45277dbeb41a455040b41dd8c467163fad685d1366f2f59207def3bcdd1d8"}, + "oban_met": {:hex, :oban_met, "1.0.3", "ea8f7a4cef3c8a7aef3b900b4458df46e83508dcbba9374c75dd590efda7a32a", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "23db1a0ee58b93afe324b221530594bdf3647a9bd4e803af762c3e00ad74b9cf"}, + "oban_web": {:hex, :oban_web, "2.11.6", "53933cb4253c4d9f1098ee311c06f07935259f0e564dcf2d66bae4cc98e317fe", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "576d94b705688c313694c2c114ca21aa0f8f2ad1b9ca45c052c5ba316d3e8d10"}, "opentelemetry": {:hex, :opentelemetry, "1.6.0", "0954dbe12f490ee7b126c9e924cf60141b1238a02dfc700907eadde4dcc20460", [:rebar3], [{:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "5fd0123d65d2649f10e478e7444927cd9fbdffcaeb8c1c2fcae3d486d18c5e62"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.1", "e071429a37441a0fe9097eeea0ff921ebadce8eba8e1ce297b05a43c7a0d121f", [:mix, :rebar3], [], "hexpm", "39bdb6ad740bc13b16215cb9f233d66796bbae897f3bf6eb77abb712e87c3c26"}, "opentelemetry_bandit": {:hex, :opentelemetry_bandit, "0.3.0", "2c242dfdaabd747c75f4d8331fc9c17cfc9fb1db0638309762a4fcfa6d49a147", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.15.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5aa12378f5ff7cc3368f02905693571833f9449df86211fd99f4d764720cff60"}, @@ -56,6 +63,7 @@ "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, + "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, @@ -71,7 +79,11 @@ "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, + "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.3", "4e741024b0b097fe783add06e53ae9a6f23ddc78df1010f215df0c02915ef5a8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c23f5f33cb6608542de4d04faf0f0291458c352a4648e4d28d17ee1098cddcc4"}, "sentry": {:hex, :sentry, "11.0.4", "60371c96cefd247e0fc98840bba2648f64f19aa0b8db8e938f5a98421f55b619", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "feaafc284dc204c82aadaddc884227aeaa3480decb274d30e184b9d41a700c66"}, + "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, + "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, @@ -79,6 +91,7 @@ "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, "tls_certificate_check": {:hex, :tls_certificate_check, "1.29.0", "4473005eb0bbdad215d7083a230e2e076f538d9ea472c8009fd22006a4cfc5f6", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "5b0d0e5cb0f928bc4f210df667304ed91c5bff2a391ce6bdedfbfe70a8f096c5"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, diff --git a/priv/repo/migrations/20251103104204_add_oban.exs b/priv/repo/migrations/20251103104204_add_oban.exs new file mode 100644 index 000000000..820df0f08 --- /dev/null +++ b/priv/repo/migrations/20251103104204_add_oban.exs @@ -0,0 +1,7 @@ +defmodule Admin.Repo.Migrations.AddOban do + use Ecto.Migration + + def up, do: Oban.Migration.up() + + def down, do: Oban.Migration.down(version: 1) +end diff --git a/priv/repo/migrations/20251103143048_create_notifications.exs b/priv/repo/migrations/20251103143048_create_notifications.exs new file mode 100644 index 000000000..3a26ce2aa --- /dev/null +++ b/priv/repo/migrations/20251103143048_create_notifications.exs @@ -0,0 +1,35 @@ +defmodule Admin.Repo.Migrations.CreateNotifications do + use Ecto.Migration + + def change do + create table(:notifications, primary_key: false) do + add :id, :uuid, primary_key: true + add :title, :string + add :message, :string + add :recipients, {:array, :string} + + timestamps(type: :utc_datetime) + end + + create table(:notification_logs, primary_key: false) do + add :id, :uuid, primary_key: true + add :email, :string + add :status, :string, null: false + + add :notification_id, + references(:notifications, type: :binary_id, on_delete: :delete_all), + null: false + + # Only inserted_at, stored as UTC + timestamps(type: :utc_datetime, updated_at: false) + end + + # Enforce allowed values at the DB level + create constraint(:notification_logs, :status_must_be_valid, + check: "status IN ('sent', 'failed')" + ) + + create index(:notification_logs, [:notification_id]) + create index(:notification_logs, [:email]) + end +end diff --git a/test/admin/accounts_test.exs b/test/admin/accounts_test.exs index 7affe7afa..4f8324ae4 100644 --- a/test/admin/accounts_test.exs +++ b/test/admin/accounts_test.exs @@ -402,4 +402,30 @@ defmodule Admin.AccountsTest do refute Enum.empty?(Accounts.list_users()) end end + + describe "create_member/0" do + test "without correct params" do + assert {:error, %Ecto.Changeset{} = changeset} = + Accounts.create_member(%{name: nil, email: nil, type: nil}) + + assert changeset.errors == [ + name: {"can't be blank", [validation: :required]}, + email: {"can't be blank", [validation: :required]}, + type: {"can't be blank", [validation: :required]} + ] + end + + test "with correct params" do + assert {:ok, member} = + Accounts.create_member(%{ + name: "John Doe", + email: "john@example.com", + type: "member" + }) + + assert member.name == "John Doe" + assert member.email == "john@example.com" + assert member.type == "member" + end + end end diff --git a/test/admin/mailer_worker_test.exs b/test/admin/mailer_worker_test.exs new file mode 100644 index 000000000..7c1accb64 --- /dev/null +++ b/test/admin/mailer_worker_test.exs @@ -0,0 +1,66 @@ +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/notifications_test.exs b/test/admin/notifications_test.exs new file mode 100644 index 000000000..666afa356 --- /dev/null +++ b/test/admin/notifications_test.exs @@ -0,0 +1,102 @@ +defmodule Admin.NotificationsTest do + use Admin.DataCase + + alias Admin.Notifications + + describe "notifications" do + alias Admin.Notifications.Notification + + import Admin.AccountsFixtures, only: [user_scope_fixture: 0] + import Admin.NotificationsFixtures + + @empty_attrs %{message: nil, title: nil, recipients: nil} + @invalid_email_attrs %{message: "A message", title: "title", recipients: ["test", "other"]} + + test "list_notifications/1 returns all notifications" do + scope = user_scope_fixture() + other_scope = user_scope_fixture() + notifications = notification_fixture(scope) + other_notifications = notification_fixture(other_scope) + assert Notifications.list_notifications(scope) == [notifications, other_notifications] + end + + test "get_notification!/2 returns the notification with given id" do + scope = user_scope_fixture() + notification = notification_fixture(scope) + other_scope = user_scope_fixture() + assert Notifications.get_notification!(scope, notification.id) == notification + # it is also possible to fetch it with another scope + assert Notifications.get_notification!(other_scope, notification.id) == notification + end + + 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"] + } + + scope = user_scope_fixture() + + 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"] + end + + test "create_notification/2 with invalid data returns error changeset" do + scope = user_scope_fixture() + + assert {:error, %Ecto.Changeset{}} = + Notifications.create_notification(scope, @empty_attrs) + + assert {:error, %Ecto.Changeset{}} = + Notifications.create_notification(scope, @invalid_email_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"} + + assert {:ok, %Notification{} = notification} = + Notifications.update_notification(scope, notification, update_attrs) + + assert notification.message == "some updated message" + assert notification.title == "some updated subject" + end + + test "update_notification/3 with invalid data returns error changeset" do + scope = user_scope_fixture() + notification = notification_fixture(scope) + + assert {:error, %Ecto.Changeset{}} = + Notifications.update_notification(scope, notification, @empty_attrs) + + assert {:error, %Ecto.Changeset{}} = + Notifications.update_notification(scope, notification, @invalid_email_attrs) + + assert notification == Notifications.get_notification!(scope, notification.id) + end + + test "delete_notification/2 deletes the notification" do + scope = user_scope_fixture() + notification = notification_fixture(scope) + + assert {:ok, %Notification{}} = + Notifications.delete_notification(scope, notification) + + assert_raise Ecto.NoResultsError, fn -> + Notifications.get_notification!(scope, notification.id) + end + end + + test "change_notification/2 returns a notification changeset" do + scope = user_scope_fixture() + notification = notification_fixture(scope) + assert %Ecto.Changeset{} = Notifications.change_notification(scope, notification) + end + end +end diff --git a/test/admin_web/live/notification_live_test.exs b/test/admin_web/live/notification_live_test.exs new file mode 100644 index 000000000..c120fdb60 --- /dev/null +++ b/test/admin_web/live/notification_live_test.exs @@ -0,0 +1,87 @@ +defmodule AdminWeb.ServiceMessageLiveTest do + use AdminWeb.ConnCase + + import Phoenix.LiveViewTest + import Admin.NotificationsFixtures + + @create_attrs %{title: "some title", message: "some message"} + @invalid_attrs %{title: nil, message: nil} + + setup :register_and_log_in_user + + defp create_notification(%{scope: scope}) do + notification = notification_fixture(scope) + + %{notification: notification} + end + + describe "Index" do + setup [:create_notification] + + test "lists all notifications", %{conn: conn, notification: notification} do + {:ok, _index_live, html} = live(conn, ~p"/notifications") + + assert html =~ "Mailing" + assert html =~ notification.title + end + + test "saves new notification", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/notifications") + + assert {:ok, form_live, _} = + index_live + |> element("a", "New Mail") + |> render_click() + |> follow_redirect(conn, ~p"/notifications/new") + + assert render(form_live) =~ "New Mail" + + assert form_live + |> 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 + |> form("#notification-form", + notification: @create_attrs + ) + |> render_submit() + |> follow_redirect(conn, ~p"/notifications") + + html = render(index_live) + assert html =~ "Notification created" + assert html =~ "some title" + end + + test "deletes notification in listing", %{conn: conn, notification: notification} do + {:ok, index_live, _html} = live(conn, ~p"/notifications") + + assert index_live + |> element("#notifications-#{notification.id} a", "Delete") + |> render_click() + + refute has_element?(index_live, "#notifications-#{notification.id}") + end + end + + describe "Show" do + setup [:create_notification] + + test "displays notification", %{conn: conn, notification: notification} do + {:ok, _show_live, html} = live(conn, ~p"/notifications/#{notification}") + + assert html =~ "Show Mail" + assert html =~ notification.title + end + end +end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index 8fea5550b..efbd65128 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -10,6 +10,7 @@ defmodule Admin.AccountsFixtures do alias Admin.Accounts.Scope def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def unique_user_name, do: "user#{System.unique_integer()}" def valid_user_password, do: "hello world!" def valid_user_attributes(attrs \\ %{}) do @@ -86,4 +87,18 @@ defmodule Admin.AccountsFixtures do set: [created_at: dt, authenticated_at: dt] ) end + + defp valid_member_attributes(attrs) do + Enum.into(attrs, %{ + name: unique_user_name(), + email: unique_user_email(), + type: "individual" + }) + end + + def member_fixture(attrs \\ %{}) do + {:ok, member} = attrs |> valid_member_attributes() |> Accounts.create_member() + + member + end end diff --git a/test/support/fixtures/notifications_fixtures.ex b/test/support/fixtures/notifications_fixtures.ex new file mode 100644 index 000000000..48a4d9829 --- /dev/null +++ b/test/support/fixtures/notifications_fixtures.ex @@ -0,0 +1,21 @@ +defmodule Admin.NotificationsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Admin.Notifications` context. + """ + + @doc """ + Generate a notification. + """ + def notification_fixture(scope, attrs \\ %{}) do + attrs = + Enum.into(attrs, %{ + message: "some message", + title: "some title", + recipients: ["user1@example.com", "user2@example.com"] + }) + + {:ok, notification} = Admin.Notifications.create_notification(scope, attrs) + notification + end +end