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" />
+
+
+
+ <%= if @recipient_method == "manual" do %>
+
+
+
+
+ <%= for {email, idx} <- Enum.with_index(@manual_recipients) do %>
+
+
+ <.button
+ type="button"
+ class="btn btn-soft btn-error"
+ phx-click="manual_remove_row"
+ phx-value-index={idx}
+ >
+ <.icon name="hero-trash" class="size-5" />
+
+
+ <% end %>
+
+
+ <.button type="button" class="btn btn-soft btn-secondary" phx-click="manual_add_row">
+ <.icon name="hero-plus" class="size-5" /> Add email
+
+
+ <%= if @form.errors[:recipients] do %>
+
+ {elem(@form.errors[:recipients], 0)}
+
+ <% end %>
+
+ <% else %>
+
+
+ <%= if @active_users == [] do %>
+
No active users found.
+ <% else %>
+
+
+ {length(@active_users)} active users (click to show)
+
+
+ <%= for email <- @active_users do %>
+ {email}
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(_params, _session, socket) do
+ notification =
+ Notifications.change_notification(socket.assigns.current_scope, %Notification{}, %{
+ "title" => "",
+ "message" => "",
+ "recipients" => []
+ })
+
+ # UI state: recipient_method can be "manual" or "active_users"
+ socket =
+ socket
+ |> assign(:page_title, "New Mailing")
+ |> assign(:form, notification)
+ |> assign(:recipient_method, "manual")
+ # start with one empty input
+ |> assign(:manual_recipients, [""])
+ |> assign(:active_users, [])
+ |> assign(:loading_active_users, false)
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_event("validate", %{"notification" => params}, socket) do
+ # Merge recipients from UI state before validating
+ {recipient_method, params} = ensure_recipients_from_ui(socket, params)
+
+ changeset =
+ Notifications.change_notification(socket.assigns.current_scope, %Notification{}, params)
+ |> Map.put(:action, :validate)
+
+ {:noreply,
+ socket
+ |> assign(:form, changeset)
+ |> assign(:recipient_method, recipient_method)}
+ end
+
+ @impl true
+ def handle_event("change_method", %{"recipient_method" => method}, socket) do
+ method = method || "manual"
+
+ case method do
+ "manual" ->
+ # Switch to manual; keep current manual state
+ {:noreply, assign(socket, :recipient_method, "manual")}
+
+ "active_users" ->
+ # Fetch active users and set recipients to that list
+ # You can do this async if Accounts.get_active_users/0 is slow.
+ active =
+ get_active_members()
+ # take only email
+ |> Enum.map(& &1.email)
+
+ changeset =
+ Notifications.update_recipients(
+ socket.assigns.form,
+ %{recipients: active}
+ )
+ |> Map.put(:action, :validate)
+
+ {:noreply,
+ socket
+ |> assign(:recipient_method, "active_users")
+ |> assign(:active_users, active)
+ |> assign(:form, changeset)}
+ end
+ end
+
+ @impl true
+ def handle_event("manual_add_row", _params, socket) do
+ {:noreply, update(socket, :manual_recipients, fn list -> list ++ [""] end)}
+ end
+
+ @impl true
+ def handle_event("manual_remove_row", %{"index" => idx_str}, socket) do
+ idx = parse_index(idx_str)
+
+ updated =
+ socket.assigns.manual_recipients
+ |> Enum.with_index()
+ |> Enum.reject(fn {_v, i} -> i == idx end)
+ |> Enum.map(fn {v, _i} -> v end)
+
+ {:noreply, assign(socket, :manual_recipients, updated)}
+ end
+
+ @impl true
+ def handle_event("manual_update_row", params, socket) do
+ [key | _] = Map.fetch!(params, "_target")
+ value = params[key]
+ "manual_email_" <> idx_str = key
+ idx = parse_index(idx_str)
+
+ updated =
+ socket.assigns.manual_recipients
+ |> Enum.with_index()
+ |> Enum.map(fn {v, i} -> if i == idx, do: value, else: v end)
+
+ changeset =
+ Notifications.update_recipients(
+ socket.assigns.form,
+ %{recipients: updated}
+ )
+ |> Map.put(:action, :validate)
+
+ {:noreply,
+ socket
+ |> assign(:manual_recipients, updated)
+ |> assign(:form, changeset)}
+ end
+
+ @impl true
+ def handle_event("submit", %{"notification" => params}, socket) do
+ {recipient_method, params} = ensure_recipients_from_ui(socket, params)
+
+ case Notifications.create_notification(socket.assigns.current_scope, params) do
+ {:ok, %Notification{} = notif} ->
+ Enum.each(
+ notif.recipients,
+ &(%{
+ "member_email" => &1,
+ "user_id" => socket.assigns.current_scope.user.id,
+ "notification_id" => notif.id
+ }
+ |> Admin.MailerWorker.new()
+ |> Oban.insert())
+ )
+
+ {:noreply,
+ socket
+ |> put_flash(:info, "Notification created")
+ |> push_navigate(to: ~p"/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