File size: 5,128 Bytes
c27e67a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
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