From 44e08c4319c8401f29f5db6def6dff9bf71f7407 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 5 Jun 2020 10:12:08 +0200
Subject: [PATCH] Add weekly notification

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 lib/service/notifications/scheduler.ex        |  67 +++++++++-
 lib/service/workers/notification.ex           |  64 ++++++++--
 lib/web/email/notification.ex                 |  24 ++++
 lib/web/templates/email/email.html.eex        |   4 -
 .../email/notification_each_week.html.eex     |  81 ++++++++++++
 .../email/notification_each_week.text.eex     |  14 +++
 test/service/notifications/scheduler_test.exs |  88 +++++++++++++
 test/service/workers/notification_test.exs    | 116 ++++++++++++++++++
 8 files changed, 440 insertions(+), 18 deletions(-)
 create mode 100644 lib/web/templates/email/notification_each_week.html.eex
 create mode 100644 lib/web/templates/email/notification_each_week.text.eex

diff --git a/lib/service/notifications/scheduler.ex b/lib/service/notifications/scheduler.ex
index f4834a479..c288fcdcf 100644
--- a/lib/service/notifications/scheduler.ex
+++ b/lib/service/notifications/scheduler.ex
@@ -7,7 +7,7 @@ defmodule Mobilizon.Service.Notifications.Scheduler do
   alias Mobilizon.Events.{Event, Participant}
   alias Mobilizon.Service.Workers.Notification
   alias Mobilizon.Users
-  alias Mobilizon.Users.Setting
+  alias Mobilizon.Users.{Setting, User}
   require Logger
 
   def before_event_notification(%Participant{
@@ -73,10 +73,75 @@ defmodule Mobilizon.Service.Notifications.Scheduler do
 
   def on_day_notification(_), do: {:ok, nil}
 
+  def weekly_notification(%Participant{
+        event: %Event{begins_on: begins_on},
+        actor: %Actor{user_id: user_id}
+      })
+      when not is_nil(user_id) do
+    %User{settings: settings, locale: locale} = Users.get_user_with_settings!(user_id)
+
+    case settings do
+      %Setting{notification_each_week: true, timezone: timezone} ->
+        %DateTime{} = begins_on_shifted = shift_zone(begins_on, timezone)
+
+        Logger.debug(
+          "Participation event start at #{inspect(begins_on_shifted)} (user timezone is #{
+            timezone
+          })"
+        )
+
+        notification_date =
+          unless begins_on < DateTime.utc_now() do
+            notification_day = calculate_first_day_of_week(DateTime.to_date(begins_on), locale)
+
+            {:ok, %NaiveDateTime{} = notification_date} =
+              notification_day |> NaiveDateTime.new(~T[08:00:00])
+
+            # This is the datetime when the notification should be sent
+            {:ok, %DateTime{} = notification_date} =
+              DateTime.from_naive(notification_date, timezone)
+
+            unless notification_date < DateTime.utc_now() do
+              notification_date
+            else
+              nil
+            end
+          else
+            nil
+          end
+
+        Logger.debug(
+          "Participation notification should be sent at #{inspect(notification_date)} (user timezone)"
+        )
+
+        if is_nil(notification_date) do
+          {:ok, "Too late to send weekly notifications"}
+        else
+          Notification.enqueue(:weekly_notification, %{user_id: user_id},
+            scheduled_at: notification_date
+          )
+        end
+
+      _ ->
+        {:ok, "User has disabled weekly notifications"}
+    end
+  end
+
+  def weekly_notification(_), do: {:ok, nil}
+
   defp shift_zone(datetime, timezone) do
     case DateTime.shift_zone(datetime, timezone) do
       {:ok, shift_datetime} -> shift_datetime
       {:error, _} -> datetime
     end
   end
+
+  defp calculate_first_day_of_week(%Date{} = date, locale) do
+    day_number = Date.day_of_week(date)
+    first_day_number = Cldr.Calendar.first_day_for_locale(locale)
+
+    if day_number == first_day_number,
+      do: date,
+      else: calculate_first_day_of_week(Date.add(date, -1), locale)
+  end
 end
diff --git a/lib/service/workers/notification.ex b/lib/service/workers/notification.ex
index 3a9a76701..099bf3245 100644
--- a/lib/service/workers/notification.ex
+++ b/lib/service/workers/notification.ex
@@ -28,29 +28,55 @@ defmodule Mobilizon.Service.Workers.Notification do
   end
 
   def perform(%{"op" => "on_day_notification", "user_id" => user_id}, _job) do
-    %User{locale: locale, settings: %Setting{timezone: timezone, notification_on_day: true}} =
-      user = Users.get_user_with_settings!(user_id)
-
-    now = DateTime.utc_now()
-    %DateTime{} = now_shifted = shift_zone(now, timezone)
-    start = %{now_shifted | hour: 8, minute: 0, second: 0, microsecond: {0, 0}}
-    tomorrow = DateTime.add(start, 3600 * 24)
-
-    with %Page{
+    with %User{locale: locale, settings: %Setting{timezone: timezone, notification_on_day: true}} =
+           user <- Users.get_user_with_settings!(user_id),
+         {start, tomorrow} <- calculate_start_end(1, timezone),
+         %Page{
            elements: participations,
            total: total
-         } <-
+         }
+         when total > 0 <-
            Events.list_participations_for_user(user_id, start, tomorrow, 1, 5),
-         true <-
-           Enum.all?(participations, fn participation ->
+         participations <-
+           Enum.filter(participations, fn participation ->
              participation.event.status == :confirmed
            end),
-         true <- total > 0 do
+         true <- length(participations) > 0 do
       user
       |> Notification.on_day_notification(participations, total, locale)
       |> Mailer.deliver_later()
 
       :ok
+    else
+      _ -> :ok
+    end
+  end
+
+  def perform(%{"op" => "weekly_notification", "user_id" => user_id}, _job) do
+    with %User{
+           locale: locale,
+           settings: %Setting{timezone: timezone, notification_each_week: true}
+         } = user <- Users.get_user_with_settings!(user_id),
+         {start, end_week} <- calculate_start_end(7, timezone),
+         %Page{
+           elements: participations,
+           total: total
+         }
+         when total > 0 <-
+           Events.list_participations_for_user(user_id, start, end_week, 1, 5),
+         participations <-
+           Enum.filter(participations, fn participation ->
+             participation.event.status == :confirmed
+           end),
+         true <- length(participations) > 0 do
+      user
+      |> Notification.weekly_notification(participations, total, locale)
+      |> Mailer.deliver_later()
+
+      :ok
+    else
+      _err ->
+        :ok
     end
   end
 
@@ -60,4 +86,16 @@ defmodule Mobilizon.Service.Workers.Notification do
       {:error, _} -> datetime
     end
   end
+
+  defp calculate_start_end(days, timezone) do
+    now = DateTime.utc_now()
+    %DateTime{} = now_shifted = shift_zone(now, timezone)
+    start = %{now_shifted | hour: 8, minute: 0, second: 0, microsecond: {0, 0}}
+
+    {:ok, %NaiveDateTime{} = tomorrow} =
+      Date.utc_today() |> Date.add(days) |> NaiveDateTime.new(~T[08:00:00])
+
+    {:ok, %DateTime{} = tomorrow} = DateTime.from_naive(tomorrow, timezone)
+    {start, tomorrow}
+  end
 end
diff --git a/lib/web/email/notification.ex b/lib/web/email/notification.ex
index 7d14185e6..0684f271a 100644
--- a/lib/web/email/notification.ex
+++ b/lib/web/email/notification.ex
@@ -56,4 +56,28 @@ defmodule Mobilizon.Web.Email.Notification do
     |> assign(:subject, subject)
     |> render(:on_day_notification)
   end
+
+  def weekly_notification(
+        %User{email: email, settings: %Setting{timezone: timezone}},
+        participations,
+        total,
+        locale \\ "en"
+      ) do
+    Gettext.put_locale(locale)
+    participation = hd(participations)
+
+    subject =
+      ngettext("One event planned this week", "%{nb_events} events planned this week", total,
+        nb_events: total
+      )
+
+    Email.base_email(to: email, subject: subject)
+    |> assign(:locale, locale)
+    |> assign(:participation, participation)
+    |> assign(:participations, participations)
+    |> assign(:total, total)
+    |> assign(:timezone, timezone)
+    |> assign(:subject, subject)
+    |> render(:notification_each_week)
+  end
 end
diff --git a/lib/web/templates/email/email.html.eex b/lib/web/templates/email/email.html.eex
index eef14eb44..8e1e61855 100644
--- a/lib/web/templates/email/email.html.eex
+++ b/lib/web/templates/email/email.html.eex
@@ -42,10 +42,6 @@
 </style>
 </head>
 <body style="background-color: #f4f4f4; margin: 0 !important; padding: 0 !important;">
-<!-- HIDDEN PREHEADER TEXT -->
-<!--<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; font-family: 'Lato', Helvetica, Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
-    Looks like you tried signing in a few too many times. Let's see if we can get you back into your account.
-</div>-->
 <table border="0" cellpadding="0" cellspacing="0" width="100%">
     <!-- LOGO -->
     <tr>
diff --git a/lib/web/templates/email/notification_each_week.html.eex b/lib/web/templates/email/notification_each_week.html.eex
new file mode 100644
index 000000000..3f3a30ac5
--- /dev/null
+++ b/lib/web/templates/email/notification_each_week.html.eex
@@ -0,0 +1,81 @@
+<!-- HERO -->
+<tr>
+  <td bgcolor="#424056" align="center" style="padding: 0px 10px 0px 10px;">
+    <!--[if (gte mso 9)|(IE)]>
+      <table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
+        <tr>
+          <td align="center" valign="top" width="600">
+    <![endif]-->
+    <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
+      <tr>
+        <td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
+          <h1 style="font-size: 48px; font-weight: 400; margin: 0;">
+            <%= gettext "Events this week" %>
+          </h1>
+        </td>
+      </tr>
+    </table>
+    <!--[if (gte mso 9)|(IE)]>
+    </td>
+  </tr>
+</table>
+    <![endif]-->
+  </td>
+</tr>
+<!-- COPY BLOCK -->
+<tr>
+  <td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
+    <!--[if (gte mso 9)|(IE)]>
+      <table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
+        <tr>
+          <td align="center" valign="top" width="600">
+    <![endif]-->
+    <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
+      <!-- COPY -->
+      <tr>
+        <td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
+          <p style="margin: 0;">
+            <%= ngettext "You have one event this week:", "You have %{total} events this week:", @total, total: @total %>
+          </p>
+        </td>
+      </tr>
+      <tr>
+        <td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
+          <%= if @total > 1 do %>
+          <ul style="margin: 0;">
+            <%= for participation <- @participations do %>
+              <li>
+                <strong>
+                  <%= participation.event.begins_on |> DateTime.shift_zone!(@timezone) |> datetime_to_string(@locale) %>
+                </strong>
+                <a href="<%= page_url(Mobilizon.Web.Endpoint, :event, participation.event.uuid) %>" target="_blank">
+                  <%= participation.event.title %>
+                </a>
+              </li>
+            <% end %>
+          </ul>
+          <% else %>
+            <strong>
+              <%= @participation.event.begins_on |> DateTime.shift_zone!(@timezone) |> datetime_to_string(@locale) %>
+            </strong>
+            <a href="<%= page_url(Mobilizon.Web.Endpoint, :event, @participation.event.uuid) %>" target="_blank">
+              <%= @participation.event.title %>
+            </a>
+          <% end %>
+        </td>
+      </tr>
+      <tr>
+        <td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
+          <p style="margin: 0">
+            <%= ngettext "If you need to cancel your participation, just access the event page through the link above and click on the participation button.", "If you need to cancel your participation, just access the event page through the links above and click on the participation button.", @total %>
+          </p>
+        </td>
+      </tr>
+    </table>
+    <!--[if (gte mso 9)|(IE)]>
+    </td>
+  </tr>
+</table>
+    <![endif]-->
+  </td>
+</tr>
diff --git a/lib/web/templates/email/notification_each_week.text.eex b/lib/web/templates/email/notification_each_week.text.eex
new file mode 100644
index 000000000..975e6d539
--- /dev/null
+++ b/lib/web/templates/email/notification_each_week.text.eex
@@ -0,0 +1,14 @@
+<%= gettext "Events this week" %>
+==
+
+<%= ngettext "You have one event this week:", "You have %{total} events this week:", @total, total: @total %>
+
+<%= if @total > 1 do %>
+  <%= for participation <- @participations do %>
+  - <%= participation.event.begins_on |> DateTime.shift_zone!(@timezone) |> datetime_to_string(@locale) %> - <%= participation.event.title %> <%= page_url(Mobilizon.Web.Endpoint, :event, participation.event.uuid) %>
+  <% end %>
+<% else %>
+  <%= DateTime.shift_zone!(@participation.event.begins_on, @timezone) |> datetime_to_string(@locale) %> - <%= @participation.event.title %> <%= page_url(Mobilizon.Web.Endpoint, :event, @participation.event.uuid) %>
+<% end %>
+
+<%= ngettext "If you need to cancel your participation, just access the event page through the link above and click on the participation button.", "If you need to cancel your participation, just access the event page through the links above and click on the participation button.", @total %>
diff --git a/test/service/notifications/scheduler_test.exs b/test/service/notifications/scheduler_test.exs
index d335ebb4a..966a07e55 100644
--- a/test/service/notifications/scheduler_test.exs
+++ b/test/service/notifications/scheduler_test.exs
@@ -144,4 +144,92 @@ defmodule Mobilizon.Service.Notifications.SchedulerTest do
       Scheduler.on_day_notification(participant)
     end
   end
+
+  describe "Joining an event registers a job for notification on week of the event" do
+    test "if the user has allowed it" do
+      %User{id: user_id} = user = insert(:user, locale: "fr")
+
+      settings =
+        insert(:settings, user_id: user_id, notification_each_week: true, timezone: "Europe/Paris")
+
+      user = Map.put(user, :settings, settings)
+      actor = insert(:actor, user: user)
+
+      # Make sure event happens next week
+      %Date{} = event_day = Date.utc_today() |> Date.add(7)
+      {:ok, %NaiveDateTime{} = event_date} = event_day |> NaiveDateTime.new(~T[16:00:00])
+      {:ok, begins_on} = DateTime.from_naive(event_date, "Etc/UTC")
+
+      %Event{} = event = insert(:event, begins_on: begins_on)
+
+      %Participant{} = participant = insert(:participant, actor: actor, event: event)
+
+      Scheduler.weekly_notification(participant)
+
+      {:ok, scheduled_at} =
+        begins_on
+        |> DateTime.to_date()
+        |> calculate_first_day_of_week("fr")
+        |> NaiveDateTime.new(~T[08:00:00])
+
+      {:ok, scheduled_at} = DateTime.from_naive(scheduled_at, "Europe/Paris")
+
+      assert_enqueued(
+        worker: Notification,
+        args: %{user_id: user_id, op: :weekly_notification},
+        scheduled_at: scheduled_at
+      )
+    end
+
+    test "not if the user hasn't allowed it" do
+      %User{id: user_id} = user = insert(:user)
+      actor = insert(:actor, user: user)
+
+      %Participant{} = participant = insert(:participant, actor: actor)
+
+      Scheduler.weekly_notification(participant)
+
+      refute_enqueued(
+        worker: Notification,
+        args: %{user_id: user_id, op: :weekly_notification}
+      )
+    end
+
+    test "not if it's too late" do
+      %User{id: user_id} = user = insert(:user)
+
+      settings =
+        insert(:settings, user_id: user_id, notification_on_day: true, timezone: "Europe/Paris")
+
+      user = Map.put(user, :settings, settings)
+      actor = insert(:actor, user: user)
+
+      {:ok, begins_on} =
+        Date.utc_today()
+        |> calculate_first_day_of_week("fr")
+        |> NaiveDateTime.new(~T[05:00:00])
+
+      {:ok, begins_on} = DateTime.from_naive(begins_on, "Europe/Paris")
+
+      %Event{} = event = insert(:event, begins_on: begins_on)
+
+      %Participant{} = participant = insert(:participant, actor: actor, event: event)
+
+      Scheduler.weekly_notification(participant)
+
+      refute_enqueued(
+        worker: Notification,
+        args: %{user_id: user_id, op: :weekly_notification}
+      )
+    end
+  end
+
+  defp calculate_first_day_of_week(%Date{} = date, locale) do
+    day_number = Date.day_of_week(date)
+    first_day_number = Cldr.Calendar.first_day_for_locale(locale)
+
+    if day_number == first_day_number,
+      do: date,
+      else: calculate_first_day_of_week(Date.add(date, -1), locale)
+  end
 end
diff --git a/test/service/workers/notification_test.exs b/test/service/workers/notification_test.exs
index d6cf1ec8c..a09211912 100644
--- a/test/service/workers/notification_test.exs
+++ b/test/service/workers/notification_test.exs
@@ -207,4 +207,120 @@ defmodule Mobilizon.Service.Workers.NotificationTest do
       )
     end
   end
+
+  describe "A weekly_notification job sends an email" do
+    test "if the user is still participating" do
+      %User{id: user_id} = user = insert(:user)
+
+      settings =
+        insert(:settings, user_id: user_id, notification_each_week: true, timezone: "Europe/Paris")
+
+      user = Map.put(user, :settings, settings)
+      %Actor{} = actor = insert(:actor, user: user)
+
+      %Participant{} = participant = insert(:participant, role: :participant, actor: actor)
+
+      Notification.perform(
+        %{"op" => "weekly_notification", "user_id" => user_id},
+        nil
+      )
+
+      assert_delivered_email(
+        NotificationMailer.weekly_notification(
+          user,
+          [participant],
+          1
+        )
+      )
+    end
+
+    test "unless the person is no longer participating" do
+      %Event{id: event_id} = insert(:event)
+
+      %User{id: user_id} = user = insert(:user)
+
+      settings =
+        insert(:settings, user_id: user_id, notification_each_week: true, timezone: "Europe/Paris")
+
+      user = Map.put(user, :settings, settings)
+      %Actor{} = actor = insert(:actor, user: user)
+
+      {:ok, %Participant{} = participant} =
+        Events.create_participant(%{actor_id: actor.id, event_id: event_id, role: :participant})
+
+      actor = Map.put(participant.actor, :user, user)
+      participant = Map.put(participant, :actor, actor)
+
+      assert {:ok, %Participant{}} = Events.delete_participant(participant)
+
+      Notification.perform(
+        %{"op" => "weekly_notification", "user_id" => user_id},
+        nil
+      )
+
+      refute_delivered_email(
+        NotificationMailer.weekly_notification(
+          user,
+          [participant],
+          1
+        )
+      )
+    end
+
+    test "unless the event has been cancelled" do
+      %User{id: user_id} = user = insert(:user)
+
+      settings =
+        insert(:settings, user_id: user_id, notification_each_week: true, timezone: "Europe/Paris")
+
+      user = Map.put(user, :settings, settings)
+      %Actor{} = actor = insert(:actor, user: user)
+      %Event{} = event = insert(:event, status: :cancelled)
+
+      %Participant{} =
+        participant = insert(:participant, role: :participant, event: event, actor: actor)
+
+      Notification.perform(
+        %{"op" => "weekly_notification", "user_id" => user_id},
+        nil
+      )
+
+      refute_delivered_email(
+        NotificationMailer.weekly_notification(
+          user,
+          [participant],
+          1
+        )
+      )
+    end
+
+    test "with a lot of events" do
+      %User{id: user_id} = user = insert(:user)
+
+      settings =
+        insert(:settings, user_id: user_id, notification_each_week: true, timezone: "Europe/Paris")
+
+      user = Map.put(user, :settings, settings)
+      %Actor{} = actor = insert(:actor, user: user)
+
+      participants =
+        Enum.reduce(0..10, [], fn _i, acc ->
+          %Participant{} = participant = insert(:participant, role: :participant, actor: actor)
+          acc ++ [participant]
+        end)
+
+      Notification.perform(
+        %{"op" => "weekly_notification", "user_id" => user_id},
+        nil
+      )
+
+      refute_delivered_email(
+        NotificationMailer.weekly_notification(
+          user,
+          participants,
+          3
+        )
+      )
+    end
+  end
 end