From 54f70717c458af0ce919988b5d293c38f162cc65 Mon Sep 17 00:00:00 2001
From: Uku Taht <uku.taht@gmail.com>
Date: Wed, 9 Dec 2020 11:00:14 +0200
Subject: [PATCH] Add basic spike notifications

Co-authored-by: Jerod Santo <jerod@changelog.com>
---
 config/config.exs                             |  7 ++-
 config/releases.exs                           |  7 ++-
 lib/plausible/site/schema.ex                  |  1 +
 lib/plausible/site/spike_notification.ex      | 30 +++++++++++++
 lib/plausible_web/email.ex                    |  9 +++-
 .../email/spike_notification.html.eex         |  1 +
 lib/workers/spike_notifier.ex                 | 39 ++++++++++++++++
 mix.exs                                       |  3 +-
 mix.lock                                      |  1 +
 ...20201208173543_add_spike_notifications.exs | 14 ++++++
 test/support/factory.ex                       |  4 ++
 test/workers/spike_notifier_test.exs          | 44 +++++++++++++++++++
 12 files changed, 154 insertions(+), 6 deletions(-)
 create mode 100644 lib/plausible/site/spike_notification.ex
 create mode 100644 lib/plausible_web/templates/email/spike_notification.html.eex
 create mode 100644 lib/workers/spike_notifier.ex
 create mode 100644 priv/repo/migrations/20201208173543_add_spike_notifications.exs
 create mode 100644 test/workers/spike_notifier_test.exs

diff --git a/config/config.exs b/config/config.exs
index cc3e1044..55326924 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -118,7 +118,9 @@ extra_cron = [
   # Daily at midday
   {"0 12 * * *", Plausible.Workers.SendCheckStatsEmails},
   # Every 10 minutes
-  {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates}
+  {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates},
+  # Every 15 minutes
+  {"*/15 * * * *", Plausible.Workers.SpikeNotifier}
 ]
 
 base_queues = [rotate_salts: 1]
@@ -130,7 +132,8 @@ extra_queues = [
   site_setup_emails: 1,
   trial_notification_emails: 1,
   schedule_email_reports: 1,
-  send_email_reports: 1
+  send_email_reports: 1,
+  spike_notifications: 1
 ]
 
 config :plausible, Oban,
diff --git a/config/releases.exs b/config/releases.exs
index a84b9306..78fa2f57 100644
--- a/config/releases.exs
+++ b/config/releases.exs
@@ -157,7 +157,9 @@ extra_cron = [
   # Daily at midday
   {"0 12 * * *", Plausible.Workers.SendCheckStatsEmails},
   # Every 10 minutes
-  {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates}
+  {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates},
+  # Every 15 minutes
+  {"*/15 * * * *", Plausible.Workers.SpikeNotifier}
 ]
 
 base_queues = [rotate_salts: 1]
@@ -169,7 +171,8 @@ extra_queues = [
   site_setup_emails: 1,
   trial_notification_emails: 1,
   schedule_email_reports: 1,
-  send_email_reports: 1
+  send_email_reports: 1,
+  spike_notifications: 1
 ]
 
 config :plausible, Oban,
diff --git a/lib/plausible/site/schema.ex b/lib/plausible/site/schema.ex
index 8fe6582c..985296dc 100644
--- a/lib/plausible/site/schema.ex
+++ b/lib/plausible/site/schema.ex
@@ -14,6 +14,7 @@ defmodule Plausible.Site do
     has_one :weekly_report, Plausible.Site.WeeklyReport
     has_one :monthly_report, Plausible.Site.MonthlyReport
     has_one :custom_domain, Plausible.Site.CustomDomain
+    has_one :spike_notification, Plausible.Site.SpikeNotification
 
     timestamps()
   end
diff --git a/lib/plausible/site/spike_notification.ex b/lib/plausible/site/spike_notification.ex
new file mode 100644
index 00000000..9cca56cd
--- /dev/null
+++ b/lib/plausible/site/spike_notification.ex
@@ -0,0 +1,30 @@
+defmodule Plausible.Site.SpikeNotification do
+  use Ecto.Schema
+  import Ecto.Changeset
+
+  schema "spike_notifications" do
+    field :recipients, {:array, :string}
+    field :threshold, :integer
+    field :last_sent, :naive_datetime
+    belongs_to :site, Plausible.Site
+
+    timestamps()
+  end
+
+  def changeset(settings, attrs \\ %{}) do
+    settings
+    |> cast(attrs, [:site_id, :recipients])
+    |> validate_required([:site_id, :recipients])
+    |> unique_constraint(:site)
+  end
+
+  def add_recipient(report, recipient) do
+    report
+    |> change(recipients: report.recipients ++ [recipient])
+  end
+
+  def remove_recipient(report, recipient) do
+    report
+    |> change(recipients: List.delete(report.recipients, recipient))
+  end
+end
diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex
index d6ac2a1c..490d9965 100644
--- a/lib/plausible_web/email.ex
+++ b/lib/plausible_web/email.ex
@@ -13,7 +13,6 @@ defmodule PlausibleWeb.Email do
     |> subject("Activate your Plausible free trial")
     |> render("activation_email.html", name: user.name, link: link)
   end
-
   def welcome_email(user) do
     base_email()
     |> to(user)
@@ -94,6 +93,14 @@ defmodule PlausibleWeb.Email do
     |> render("weekly_report.html", Keyword.put(assigns, :site, site))
   end
 
+  def spike_notification(email, site, current_visitors) do
+    base_email()
+    |> to(email)
+    |> tag("spike-notification")
+    |> subject("Traffic spike on #{site.domain}")
+    |> render("spike_notification.html", %{current_visitors: current_visitors})
+  end
+
   def cancellation_email(user) do
     base_email()
     |> to(user.email)
diff --git a/lib/plausible_web/templates/email/spike_notification.html.eex b/lib/plausible_web/templates/email/spike_notification.html.eex
new file mode 100644
index 00000000..906bb3a6
--- /dev/null
+++ b/lib/plausible_web/templates/email/spike_notification.html.eex
@@ -0,0 +1 @@
+Yours site has <%= @current_visitors %> visitors. Nice!
diff --git a/lib/workers/spike_notifier.ex b/lib/workers/spike_notifier.ex
new file mode 100644
index 00000000..1faa9060
--- /dev/null
+++ b/lib/workers/spike_notifier.ex
@@ -0,0 +1,39 @@
+defmodule Plausible.Workers.SpikeNotifier do
+  use Plausible.Repo
+  alias Plausible.Stats.Query
+  use Oban.Worker, queue: :spike_notifications
+  @at_most_every "12 hours"
+
+  @impl Oban.Worker
+  def perform(_args, _job, clickhouse \\ Plausible.Stats.Clickhouse) do
+    notifications = Repo.all(
+      from sn in Plausible.Site.SpikeNotification,
+      where: is_nil(sn.last_sent),
+      or_where: sn.last_sent < fragment("now() - INTERVAL ?", @at_most_every)
+    )
+
+    for notification <- notifications do
+      notification = Repo.preload(notification, :site)
+      query = Query.from(notification.site.timezone, %{"period" => "realtime"})
+      current_visitors = clickhouse.current_visitors(notification.site, query)
+      notify(notification, current_visitors)
+    end
+  end
+
+  def notify(notification, current_visitors) do
+    if current_visitors >= notification.threshold do
+      for recipient <- notification.recipients do
+        send_notification(recipient, notification.site, current_visitors)
+      end
+    end
+  end
+
+  defp send_notification(recipient, site, current_visitors) do
+    template = PlausibleWeb.Email.spike_notification(recipient, site, current_visitors)
+    try do
+      Plausible.Mailer.send_email(template)
+    rescue
+      _ -> nil
+    end
+  end
+end
diff --git a/mix.exs b/mix.exs
index 017c2982..146b1a4c 100644
--- a/mix.exs
+++ b/mix.exs
@@ -90,7 +90,8 @@ defmodule Plausible.MixProject do
       {:sshex, "2.2.1"},
       {:geolix, "~> 1.0"},
       {:clickhouse_ecto, git: "https://github.com/plausible/clickhouse_ecto.git"},
-      {:geolix_adapter_mmdb2, "~> 0.5.0"}
+      {:geolix_adapter_mmdb2, "~> 0.5.0"},
+      {:mix_test_watch, "~> 1.0", only: :dev}
     ]
   end
 
diff --git a/mix.lock b/mix.lock
index 90c62b08..b4a89c0b 100644
--- a/mix.lock
+++ b/mix.lock
@@ -44,6 +44,7 @@
   "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
   "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
   "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
+  "mix_test_watch": {:hex, :mix_test_watch, "1.0.2", "34900184cbbbc6b6ed616ed3a8ea9b791f9fd2088419352a6d3200525637f785", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "47ac558d8b06f684773972c6d04fcc15590abdb97aeb7666da19fcbfdc441a07"},
   "mmdb2_decoder": {:hex, :mmdb2_decoder, "3.0.0", "54828676a36e75e9a25bc9a0bb0598d4c7fcc767bf0b40674850b22e05b7b6cc", [:mix], [], "hexpm", "359dc9242915538d1dceb9f6d96c72201dca76ce62e49d22e2ed1e86f20bea8e"},
   "nanoid": {:hex, :nanoid, "2.0.2", "f3f7b4bf103ab6667f22beb00b6315825ee3f30100dd2c93d534e5c02164e857", [:mix], [], "hexpm", "3095cb1fac7bbc78843a8ccd99f1af375d0da1d3ebaa8552e846b73438c0c44f"},
   "oauther": {:hex, :oauther, "1.1.1", "7d8b16167bb587ecbcddd3f8792beb9ec3e7b65c1f8ebd86b8dd25318d535752", [:mix], [], "hexpm", "9374f4302045321874cccdc57eb975893643bd69c3b22bf1312dab5f06e5788e"},
diff --git a/priv/repo/migrations/20201208173543_add_spike_notifications.exs b/priv/repo/migrations/20201208173543_add_spike_notifications.exs
new file mode 100644
index 00000000..a5386470
--- /dev/null
+++ b/priv/repo/migrations/20201208173543_add_spike_notifications.exs
@@ -0,0 +1,14 @@
+defmodule Plausible.Repo.Migrations.AddSpikeNotifications do
+  use Ecto.Migration
+
+  def change do
+    create table(:spike_notifications) do
+      add :site_id, references(:sites), null: false
+      add :threshold, :integer, null: false
+      add :last_sent, :naive_datetime
+      add :recipients, {:array, :citext}, null: false, default: []
+
+      timestamps()
+    end
+  end
+end
diff --git a/test/support/factory.ex b/test/support/factory.ex
index ec7cde25..c0a34115 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -20,6 +20,10 @@ defmodule Plausible.Factory do
     merge_attributes(user, attrs)
   end
 
+  def spike_notification_factory do
+    %Plausible.Site.SpikeNotification{}
+  end
+
   def site_factory do
     domain = sequence(:domain, &"example-#{&1}.com")
 
diff --git a/test/workers/spike_notifier_test.exs b/test/workers/spike_notifier_test.exs
new file mode 100644
index 00000000..fb673cb6
--- /dev/null
+++ b/test/workers/spike_notifier_test.exs
@@ -0,0 +1,44 @@
+defmodule Plausible.Workers.SpikeNotifierTest do
+  use Plausible.DataCase
+  use Bamboo.Test
+  import Double
+  alias Plausible.Workers.SpikeNotifier
+
+  test "does not notify anyone if current visitors does not exceed notification threshold" do
+    site = insert(:site)
+    insert(:spike_notification, site: site, threshold: 10, recipients: ["jerod@example.com", "uku@example.com"])
+
+    clickhouse_stub = stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site, _query -> 5 end)
+    SpikeNotifier.perform(nil, nil, clickhouse_stub)
+
+    assert_no_emails_delivered()
+  end
+
+  test "notifies all recipients when traffic is higher than configured threshold" do
+    site = insert(:site)
+    insert(:spike_notification, site: site, threshold: 10, recipients: ["jerod@example.com", "uku@example.com"])
+
+    clickhouse_stub = stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site, _query -> 10 end)
+    SpikeNotifier.perform(nil, nil, clickhouse_stub)
+
+    assert_email_delivered_with(
+      subject: "Traffic spike on #{site.domain}",
+      to: [nil: "jerod@example.com"]
+    )
+
+    assert_email_delivered_with(
+      subject: "Traffic spike on #{site.domain}",
+      to: [nil: "uku@example.com"]
+    )
+  end
+
+  test "does not notify anyone if a notification already went out in the last 12 hours" do
+    site = insert(:site)
+    insert(:spike_notification, site: site, threshold: 10, recipients: ["jerod@example.com", "uku@example.com"], last_sent: Timex.now())
+
+    clickhouse_stub = stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site, _query -> 10 end)
+    SpikeNotifier.perform(nil, nil, clickhouse_stub)
+
+    assert_no_emails_delivered()
+  end
+end
-- 
GitLab