defmodule TarakanWeb.AlertLive do @moduledoc """ Watchlist management for finding alerts. Each watchlist is a named set of `owner/name` repository refs; the daily `Tarakan.Billing.AlertWorker` emails the account when verified or fixed findings first appear on those repositories (when `notify` is on). """ use TarakanWeb, :live_view alias Tarakan.Billing alias Tarakan.Billing.Watchlist @impl true def mount(_params, _session, socket) do account = socket.assigns.current_scope.account {:ok, socket |> assign(:page_title, "Alerts") |> assign(:editing_id, nil) |> assign(:form, watchlist_form(%{})) |> stream(:watchlists, Billing.list_watchlists(account), dom_id: fn watchlist -> "watchlist-#{watchlist.id}" end )} end @impl true def handle_event("validate", %{"watchlist" => params}, socket) do {:noreply, assign(socket, :form, watchlist_form(params))} end def handle_event("save", %{"watchlist" => params}, socket) do attrs = %{"name" => params["name"], "entries" => parse_entries(params["entries"])} case socket.assigns.editing_id do nil -> create(socket, attrs) id -> save_edit(socket, id, attrs) end end def handle_event("edit", %{"id" => id}, socket) do watchlist = find_watchlist(socket, id) if watchlist do {:noreply, socket |> assign(:editing_id, watchlist.id) |> assign( :form, watchlist_form(%{ "name" => watchlist.name, "entries" => Enum.join(watchlist.entries, "\n") }) )} else {:noreply, socket} end end def handle_event("cancel_edit", _params, socket) do {:noreply, socket |> assign(:editing_id, nil) |> assign(:form, watchlist_form(%{}))} end def handle_event("delete", %{"id" => id}, socket) do with %Watchlist{} = watchlist <- find_watchlist(socket, id), {:ok, _} <- Billing.delete_watchlist(socket.assigns.current_scope, watchlist) do {:noreply, socket |> stream_delete(:watchlists, watchlist) |> put_flash(:info, "Watchlist deleted.")} else _other -> {:noreply, put_flash(socket, :error, "Could not delete that watchlist.")} end end def handle_event("toggle_notify", %{"id" => id}, socket) do with %Watchlist{} = watchlist <- find_watchlist(socket, id), {:ok, updated} <- Billing.update_watchlist(socket.assigns.current_scope, watchlist, %{ "notify" => !watchlist.notify }) do {:noreply, stream_insert(socket, :watchlists, updated)} else _other -> {:noreply, put_flash(socket, :error, "Could not update that watchlist.")} end end defp create(socket, attrs) do case Billing.create_watchlist(socket.assigns.current_scope, attrs) do {:ok, watchlist} -> {:noreply, socket |> stream_insert(:watchlists, watchlist, at: 0) |> assign(:form, watchlist_form(%{})) |> put_flash(:info, "Watchlist created.")} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, put_flash(socket, :error, changeset_message(changeset))} end end defp save_edit(socket, id, attrs) do with %Watchlist{} = watchlist <- find_watchlist(socket, id), {:ok, updated} <- Billing.update_watchlist(socket.assigns.current_scope, watchlist, attrs) do {:noreply, socket |> stream_insert(:watchlists, updated) |> assign(:editing_id, nil) |> assign(:form, watchlist_form(%{})) |> put_flash(:info, "Watchlist updated.")} else {:error, %Ecto.Changeset{} = changeset} -> {:noreply, put_flash(socket, :error, changeset_message(changeset))} _other -> {:noreply, put_flash(socket, :error, "Could not update that watchlist.")} end end # Watchlists are small and owner-scoped; re-reading the list keeps the # stream and form lookups simple. defp find_watchlist(socket, id) do id = if is_binary(id), do: String.to_integer(id), else: id account = socket.assigns.current_scope.account Enum.find(Billing.list_watchlists(account), fn watchlist -> watchlist.id == id end) end defp parse_entries(nil), do: [] defp parse_entries(text) when is_binary(text) do text |> String.split("\n", trim: true) |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) end defp changeset_message(changeset) do details = changeset |> Ecto.Changeset.traverse_errors(fn {message, _meta} -> message end) |> Enum.map_join("; ", fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end) "The watchlist could not be saved: #{details}." end defp watchlist_form(params), do: to_form(params, as: :watchlist) @impl true def render(assigns) do ~H"""

Alerts

Watch repositories and get a daily email when verified or fixed findings first appear on them. Alert delivery requires an active plan (see <.link navigate={~p"/pricing"} class="text-ink underline" >pricing).

<.form for={@form} id="watchlist-form" phx-change="validate" phx-submit="save" class="space-y-5 border-2 border-strong p-5" >

{if @editing_id, do: "Edit watchlist", else: "New watchlist"}

<.input field={@form[:name]} type="text" label="Name" required maxlength="100" /> <.input field={@form[:entries]} type="textarea" label="Repositories - one owner/name per line" rows="5" placeholder="openai/codex\nacme/widget" />

{watchlist.name}

{length(watchlist.entries)} repositories ยท last alerted {Calendar.strftime(watchlist.last_notified_at, "%Y-%m-%d")}

  • {entry}
""" end end