diff --git a/lib/sanbase/ecto_enum.ex b/lib/sanbase/ecto_enum.ex
index 6371e5be7..aa1dcc7f7 100644
--- a/lib/sanbase/ecto_enum.ex
+++ b/lib/sanbase/ecto_enum.ex
@@ -35,7 +35,13 @@ defenum(SubscriptionStatusEnum, :status, [
defenum(LangEnum, :lang, ["en", "jp"])
-defenum(NotificationActionTypeEnum, :notification_action_type, [:create, :update, :delete, :alert])
+defenum(NotificationActionTypeEnum, :notification_action_type, [
+ :create,
+ :update,
+ :delete,
+ :alert,
+ :manual
+])
defenum(NotificationChannelEnum, :notification_channel, [:discord, :email, :telegram])
defenum(NotificationStatusEnum, :notification_status, [:pending, :completed, :failed])
diff --git a/lib/sanbase/notifications/template_renderer.ex b/lib/sanbase/notifications/template_renderer.ex
index aa4c9d273..084f5f145 100644
--- a/lib/sanbase/notifications/template_renderer.ex
+++ b/lib/sanbase/notifications/template_renderer.ex
@@ -2,6 +2,14 @@ defmodule Sanbase.Notifications.TemplateRenderer do
alias Sanbase.Notifications.{Notification, NotificationAction}
alias Sanbase.TemplateEngine
+ def render_content(%Notification{
+ notification_action: %NotificationAction{action_type: :manual},
+ content: content
+ })
+ when is_binary(content) do
+ String.trim(content)
+ end
+
def render_content(%Notification{
notification_action: %NotificationAction{action_type: action_type},
step: step,
diff --git a/lib/sanbase_web/live/notification/manual_notification_live.ex b/lib/sanbase_web/live/notification/manual_notification_live.ex
new file mode 100644
index 000000000..7d19ac877
--- /dev/null
+++ b/lib/sanbase_web/live/notification/manual_notification_live.ex
@@ -0,0 +1,152 @@
+defmodule SanbaseWeb.ManualNotificationLive do
+ use SanbaseWeb, :live_view
+
+ alias Sanbase.Notifications
+
+ @channel_discord :discord
+ @channel_email :email
+ @valid_channels [@channel_discord, @channel_email]
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ assign(socket,
+ page_title: "Create Manual Notification",
+ form:
+ to_form(
+ %{
+ "channels_discord" => "true",
+ "channels_email" => "false",
+ "content" => "",
+ "scheduled_at" => ""
+ },
+ as: "notification"
+ ),
+ notification: nil
+ )}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
Create Manual Notification
+
+ <.form for={@form} phx-submit="save" class="space-y-6">
+
+ <.label>Channels
+
+ <.input
+ type="checkbox"
+ field={@form[:channels_discord]}
+ value="true"
+ checked
+ label="Discord"
+ name="notification[channels_discord]"
+ />
+ <.input
+ type="checkbox"
+ field={@form[:channels_email]}
+ value="true"
+ label="Email"
+ name="notification[channels_email]"
+ />
+
+
+
+
+ <.label>Content
+ <.input type="textarea" field={@form[:content]} rows="4" required class="w-full" />
+
+
+
+ <.label>Schedule For (optional)
+ <.input type="datetime-local" field={@form[:scheduled_at]} class="w-full" />
+
+
+ <.button type="submit" phx-disable-with="Creating...">
+ Create Notification
+
+
+
+ <%= if @notification do %>
+
+ Notification created successfully!
+
+ <% end %>
+
+ """
+ end
+
+ @impl true
+ def handle_event("save", %{"notification" => notification_params}, socket) do
+ # Convert the checkbox boolean values to actual channel names
+ discord_selected? = notification_params["channels_discord"] == "true"
+ email_selected? = notification_params["channels_email"] == "true"
+
+ channels =
+ []
+ |> then(fn list -> if discord_selected?, do: [@channel_discord | list], else: list end)
+ |> then(fn list -> if email_selected?, do: [@channel_email | list], else: list end)
+ |> then(fn list -> if list == [], do: [@channel_discord], else: list end)
+
+ scheduled_at =
+ case notification_params["scheduled_at"] do
+ "" -> DateTime.utc_now()
+ datetime_str -> parse_datetime(datetime_str)
+ end
+
+ {:ok, notification_action} =
+ Notifications.create_notification_action(%{
+ action_type: :manual,
+ scheduled_at: scheduled_at,
+ status: :pending,
+ requires_verification: false,
+ verified: true
+ })
+
+ {:ok, notification} =
+ Notifications.create_notification(%{
+ notification_action_id: notification_action.id,
+ step: :once,
+ status: :pending,
+ scheduled_at: scheduled_at,
+ channels: channels,
+ display_in_ui: true,
+ content: notification_params["content"]
+ })
+
+ {:noreply,
+ assign(socket,
+ notification: notification,
+ form:
+ to_form(
+ %{
+ "channels_discord" => "true",
+ "channels_email" => "false",
+ "content" => "",
+ "scheduled_at" => ""
+ },
+ as: "notification"
+ )
+ )}
+ end
+
+ defp parse_datetime(datetime_str) do
+ [date, time] = String.split(datetime_str, "T")
+ [year, month, day] = String.split(date, "-")
+ [hour, minute] = String.split(time, ":")
+
+ {:ok, datetime} =
+ NaiveDateTime.new(
+ String.to_integer(year),
+ String.to_integer(month),
+ String.to_integer(day),
+ String.to_integer(hour),
+ String.to_integer(minute),
+ 0
+ )
+
+ DateTime.from_naive!(datetime, "Etc/UTC")
+ end
+end
diff --git a/lib/sanbase_web/router.ex b/lib/sanbase_web/router.ex
index 8b31859b2..d516c2075 100644
--- a/lib/sanbase_web/router.ex
+++ b/lib/sanbase_web/router.ex
@@ -83,6 +83,8 @@ defmodule SanbaseWeb.Router do
live "/notifications/:id", NotificationLive.Show, :show
live "/notifications/:id/show/edit", NotificationLive.Show, :edit
+ live "/notifications/manual/new", ManualNotificationLive, :new
+
resources("/reports", ReportController)
resources("/sheets_templates", SheetsTemplateController)
resources("/webinars", WebinarController)
diff --git a/priv/repo/migrations/20241029151959_add_manual_notification_action_type.exs b/priv/repo/migrations/20241029151959_add_manual_notification_action_type.exs
new file mode 100644
index 000000000..2411f364a
--- /dev/null
+++ b/priv/repo/migrations/20241029151959_add_manual_notification_action_type.exs
@@ -0,0 +1,11 @@
+defmodule Sanbase.Repo.Migrations.AddManualNotificationActionType do
+ use Ecto.Migration
+
+ def up do
+ execute("ALTER TYPE notification_action_type ADD VALUE IF NOT EXISTS 'manual'")
+ end
+
+ def down do
+ :ok
+ end
+end
diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql
index 4ffa65fee..9fc210110 100644
--- a/priv/repo/structure.sql
+++ b/priv/repo/structure.sql
@@ -63,7 +63,8 @@ CREATE TYPE public.notification_action_type AS ENUM (
'create',
'update',
'delete',
- 'alert'
+ 'alert',
+ 'manual'
);
@@ -9537,3 +9538,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20241018073651);
INSERT INTO public."schema_migrations" (version) VALUES (20241018075640);
INSERT INTO public."schema_migrations" (version) VALUES (20241029080754);
INSERT INTO public."schema_migrations" (version) VALUES (20241029082533);
+INSERT INTO public."schema_migrations" (version) VALUES (20241029151959);
diff --git a/test/sanbase/notifications/actions_test.exs b/test/sanbase/notifications/actions_test.exs
index 802e32722..54726abe8 100644
--- a/test/sanbase/notifications/actions_test.exs
+++ b/test/sanbase/notifications/actions_test.exs
@@ -282,6 +282,41 @@ defmodule Sanbase.Notifications.ActionsTest do
Sanbase.Notifications.Sender.send_notification(resolved_notification)
end
+ test "sends manual notification with custom text on Discord" do
+ custom_text = "Important announcement: System maintenance scheduled for tomorrow"
+
+ {:ok, notification_action} =
+ Notifications.create_notification_action(%{
+ action_type: :manual,
+ scheduled_at: DateTime.utc_now(),
+ status: :pending,
+ requires_verification: false,
+ verified: true
+ })
+
+ {:ok, notification} =
+ Notifications.create_notification(%{
+ notification_action_id: notification_action.id,
+ step: :once,
+ status: :pending,
+ scheduled_at: DateTime.utc_now(),
+ channels: [:discord],
+ display_in_ui: true,
+ content: custom_text
+ })
+
+ Sanbase.Notifications.MockDiscordClient
+ |> expect(:send_message, fn _webhook, message, _opts ->
+ assert String.trim(message) == custom_text
+ :ok
+ end)
+
+ Sanbase.Notifications.Sender.send_notification(notification)
+
+ notification = Notifications.get_notification!(notification.id)
+ assert notification.status == :completed
+ end
+
defp get_scheduled_at(:before), do: DateTime.utc_now()
defp get_scheduled_at(:reminder), do: DateTime.add(DateTime.utc_now(), 3600 * 24 * 27, :second)
defp get_scheduled_at(:after), do: DateTime.add(DateTime.utc_now(), 3600 * 24 * 30, :second)