From d0fbf11e4e4c5df5d7ba03adace95a6bf4f17de0 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Fri, 5 Jun 2020 10:12:08 +0200 Subject: [PATCH] Add weekly notification Signed-off-by: Thomas Citharel --- 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 @@ - - 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 @@ + + + + + + + + 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
+ + + + + +
+

+ <%= 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 %> + +
  • + <% end %> +
+ <% else %> + + <%= @participation.event.begins_on |> DateTime.shift_zone!(@timezone) |> datetime_to_string(@locale) %> + + + <%= @participation.event.title %> + + <% 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 %> +

+
+ +