defmodule Plausible.Workers.TrafficChangeNotifier do @moduledoc """ Oban service sending out traffic drop/spike notifications """ use Plausible use Plausible.Repo alias Plausible.Stats.{Clickhouse, ParsedQueryParams, QueryBuilder} alias Plausible.Site.TrafficChangeNotification alias PlausibleWeb.Router.Helpers, as: Routes use Oban.Worker, queue: :spike_notifications @min_interval_hours 12 @impl Oban.Worker def perform(_job, now \\ NaiveDateTime.utc_now(:second)) do today = NaiveDateTime.to_date(now) notifications = Repo.all( from sn in TrafficChangeNotification, where: is_nil(sn.last_sent) or sn.last_sent < ^NaiveDateTime.add(now, -@min_interval_hours, :hour), inner_join: s in assoc(sn, :site), inner_join: t in assoc(s, :team), where: not t.locked, where: is_nil(t.accept_traffic_until) or t.accept_traffic_until > ^today, preload: [site: {s, team: t}] ) for notification <- notifications, ok_to_send?(notification.site) do handle_notification(notification, now) end :ok end defp handle_notification(%TrafficChangeNotification{type: :spike} = notification, now) do current_visitors = Clickhouse.current_visitors(notification.site) if current_visitors >= notification.threshold do stats = notification.site |> get_traffic_spike_stats() |> Map.put(:current_visitors, current_visitors) notify_spike(notification, stats, now) end end defp handle_notification(%TrafficChangeNotification{type: :drop} = notification, now) do current_visitors = Clickhouse.current_visitors_12h(notification.site) if current_visitors < notification.threshold do notify_drop(notification, current_visitors, now) end end defp notify_spike(notification, stats, now) do for recipient_email <- notification.recipients do send_spike_notification(recipient_email, notification.site, stats) end notification |> TrafficChangeNotification.was_sent(now) |> Repo.update() end defp notify_drop(notification, current_visitors, now) do for recipient_email <- notification.recipients do send_drop_notification(recipient_email, notification.site, current_visitors) end notification |> TrafficChangeNotification.was_sent(now) |> Repo.update() end defp send_spike_notification(recipient_email, site, stats) do dashboard_link = if site_member?(site, recipient_email) do Routes.stats_url(PlausibleWeb.Endpoint, :stats, site.domain, []) <> "?__team=#{site.team.identifier}" end template = PlausibleWeb.Email.spike_notification( recipient_email, site, stats, dashboard_link ) Plausible.Mailer.send(template) end defp send_drop_notification(recipient_email, site, current_visitors) do site_member? = site_member?(site, recipient_email) dashboard_link = if site_member? do Routes.stats_url(PlausibleWeb.Endpoint, :stats, site.domain, []) <> "?__team=#{site.team.identifier}" end installation_link = if site_member? and Plausible.Sites.regular?(site) do Routes.site_url(PlausibleWeb.Endpoint, :installation, site.domain, flow: PlausibleWeb.Flows.review() ) <> "&__team=#{site.team.identifier}" end template = PlausibleWeb.Email.drop_notification( recipient_email, site, current_visitors, dashboard_link, installation_link ) Plausible.Mailer.send(template) end defp get_traffic_spike_stats(site) do %{} |> put_sources(site) |> put_pages(site) end @base_query_params %ParsedQueryParams{ metrics: [:visitors], pagination: %{limit: 3, offset: 0}, input_date_range: :realtime } defp put_sources(stats, site) do query = QueryBuilder.build!( site, struct!(@base_query_params, dimensions: ["visit:source"], filters: [[:is_not, "visit:source", ["Direct / None"]]] ) ) %{results: sources} = Plausible.Stats.query(site, query) Map.put(stats, :sources, sources) end defp put_pages(stats, site) do query = QueryBuilder.build!(site, struct!(@base_query_params, dimensions: ["event:page"])) %{results: pages} = Plausible.Stats.query(site, query) Map.put(stats, :pages, pages) end defp site_member?(site, recipient_email) do from(tm in Plausible.Teams.Membership, inner_join: u in assoc(tm, :user), left_join: gm in assoc(tm, :guest_memberships), where: tm.team_id == ^site.team_id, where: tm.role != :guest or gm.site_id == ^site.id, where: u.email == ^recipient_email ) |> Repo.exists?() end on_ee do defp ok_to_send?(site) do Plausible.Sites.regular?(site) or (Plausible.Sites.consolidated?(site) and Plausible.ConsolidatedView.ok_to_display?(site.team)) end else defp ok_to_send?(_site), do: always(true) end end