From a877e4d7d928979fb8c628b32ebaff580a7adacc Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Thu, 11 Apr 2019 18:25:32 +0200
Subject: [PATCH] Implement related events

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 lib/mobilizon/events/events.ex                | 50 +++++++++++++++++
 lib/mobilizon_web/resolvers/event.ex          | 46 ++++++++++++++++
 lib/mobilizon_web/schema/event.ex             |  5 ++
 ...190411161557_event_event_tag_on_delete.exs | 23 ++++++++
 test/mobilizon/events/events_test.exs         |  2 +-
 .../resolvers/event_resolver_test.exs         | 54 +++++++++++++++++++
 test/support/factory.ex                       |  3 +-
 7 files changed, 181 insertions(+), 2 deletions(-)
 create mode 100644 priv/repo/migrations/20190411161557_event_event_tag_on_delete.exs

diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex
index 42ae4b484..8c7c48079 100644
--- a/lib/mobilizon/events/events.ex
+++ b/lib/mobilizon/events/events.ex
@@ -45,6 +45,34 @@ defmodule Mobilizon.Events do
     {:ok, events, count_events}
   end
 
+  @doc """
+  Get an actor's eventual upcoming public event
+  """
+  @spec get_actor_upcoming_public_event(Actor.t(), String.t()) :: Event.t() | nil
+  def get_actor_upcoming_public_event(%Actor{id: actor_id} = _actor, not_event_uuid \\ nil) do
+    query =
+      from(
+        e in Event,
+        where:
+          e.organizer_actor_id == ^actor_id and e.visibility in [^:public, ^:unlisted] and
+            e.begins_on > ^DateTime.utc_now(),
+        order_by: [asc: :begins_on],
+        preload: [
+          :organizer_actor,
+          :tags,
+          :participants,
+          :physical_address
+        ]
+      )
+
+    query =
+      if is_nil(not_event_uuid),
+        do: query,
+        else: from(q in query, where: q.uuid != ^not_event_uuid)
+
+    Repo.one(query)
+  end
+
   def count_local_events do
     Repo.one(
       from(
@@ -274,6 +302,28 @@ defmodule Mobilizon.Events do
     Repo.all(query)
   end
 
+  @doc """
+  Find events with the same tags
+  """
+  @spec find_similar_events_by_common_tags(list(), integer()) :: {:ok, list(Event.t())}
+  def find_similar_events_by_common_tags(tags, limit \\ 2) do
+    tags_ids = Enum.map(tags, & &1.id)
+
+    query =
+      from(e in Event,
+        distinct: e.uuid,
+        join: te in "events_tags",
+        on: e.id == te.event_id,
+        where: e.begins_on > ^DateTime.utc_now(),
+        where: e.visibility in [^:public, ^:unlisted],
+        where: te.tag_id in ^tags_ids,
+        order_by: [asc: e.begins_on],
+        limit: ^limit
+      )
+
+    Repo.all(query)
+  end
+
   @doc """
   Creates a event.
 
diff --git a/lib/mobilizon_web/resolvers/event.ex b/lib/mobilizon_web/resolvers/event.ex
index ac11e1b12..5ae1710fd 100644
--- a/lib/mobilizon_web/resolvers/event.ex
+++ b/lib/mobilizon_web/resolvers/event.ex
@@ -3,12 +3,14 @@ defmodule MobilizonWeb.Resolvers.Event do
   Handles the event-related GraphQL calls
   """
   alias Mobilizon.Activity
+  alias Mobilizon.Events
   alias Mobilizon.Events.{Event, Participant}
   alias Mobilizon.Actors.Actor
   alias Mobilizon.Users.User
 
   # We limit the max number of events that can be retrieved
   @event_max_limit 100
+  @number_of_related_events 3
 
   def list_events(_parent, %{page: page, limit: limit}, _resolution)
       when limit < @event_max_limit do
@@ -43,6 +45,50 @@ defmodule MobilizonWeb.Resolvers.Event do
     {:ok, Mobilizon.Events.list_participants_for_event(uuid, 1, 10)}
   end
 
+  @doc """
+  List related events
+  """
+  def list_related_events(
+        %Event{tags: tags, organizer_actor: organizer_actor},
+        _args,
+        _resolution
+      ) do
+    # We get the organizer's next public event
+    events =
+      [Events.get_actor_upcoming_public_event(organizer_actor, uuid)] |> Enum.filter(&is_map/1)
+
+    # uniq_by : It's possible event_from_same_actor is inside events_from_tags
+    events =
+      (events ++
+         Events.find_similar_events_by_common_tags(
+           tags,
+           @number_of_related_events - length(events)
+         ))
+      |> uniq_events()
+
+    # TODO: We should use tag_relations to find more appropriate events
+
+    # We've considered all recommended events, so we fetch the latest events
+    events =
+      if @number_of_related_events - length(events) > 0 do
+        (events ++
+           Events.list_events(1, @number_of_related_events, :begins_on, :asc, true, true))
+        |> uniq_events()
+      else
+        events
+      end
+
+    events =
+      events
+      # We remove the same event from the results
+      |> Enum.filter(fn event -> event.uuid != uuid end)
+      # We return only @number_of_related_events right now
+      |> Enum.take(@number_of_related_events)
+
+    # TODO: We should use tag_relations to find more events
+    {:ok, events}
+  end
+
   @doc """
   Join an event for an actor
   """
diff --git a/lib/mobilizon_web/schema/event.ex b/lib/mobilizon_web/schema/event.ex
index c4bcb1c6c..ac72f93f2 100644
--- a/lib/mobilizon_web/schema/event.ex
+++ b/lib/mobilizon_web/schema/event.ex
@@ -56,6 +56,11 @@ defmodule MobilizonWeb.Schema.EventType do
       description: "The event's participants"
     )
 
+    field(:related_events, list_of(:event),
+      resolve: &MobilizonWeb.Resolvers.Event.list_related_events/3,
+      description: "Events related to this one"
+    )
+
     # field(:tracks, list_of(:track))
     # field(:sessions, list_of(:session))
 
diff --git a/priv/repo/migrations/20190411161557_event_event_tag_on_delete.exs b/priv/repo/migrations/20190411161557_event_event_tag_on_delete.exs
new file mode 100644
index 000000000..2ddffb82a
--- /dev/null
+++ b/priv/repo/migrations/20190411161557_event_event_tag_on_delete.exs
@@ -0,0 +1,23 @@
+defmodule Mobilizon.Repo.Migrations.EventEventTagOnDelete do
+  use Ecto.Migration
+
+  def up do
+    drop(constraint(:events_tags, "events_tags_event_id_fkey"))
+    drop(constraint(:events_tags, "events_tags_tag_id_fkey"))
+
+    alter table(:events_tags) do
+      modify(:event_id, references(:events, on_delete: :delete_all))
+      modify(:tag_id, references(:tags, on_delete: :delete_all))
+    end
+  end
+
+  def down do
+    drop(constraint(:events_tags, "events_tags_event_id_fkey"))
+    drop(constraint(:events_tags, "events_tags_tag_id_fkey"))
+
+    alter table(:events_tags) do
+      modify(:event_id, references(:events))
+      modify(:tag_id, references(:tags))
+    end
+  end
+end
diff --git a/test/mobilizon/events/events_test.exs b/test/mobilizon/events/events_test.exs
index b07283994..77a0ad0c9 100644
--- a/test/mobilizon/events/events_test.exs
+++ b/test/mobilizon/events/events_test.exs
@@ -20,7 +20,7 @@ defmodule Mobilizon.EventsTest do
 
     setup do
       actor = insert(:actor)
-      event = insert(:event, organizer_actor: actor)
+      event = insert(:event, organizer_actor: actor, visibility: :public)
       {:ok, actor: actor, event: event}
     end
 
diff --git a/test/mobilizon_web/resolvers/event_resolver_test.exs b/test/mobilizon_web/resolvers/event_resolver_test.exs
index 636386364..16473ff64 100644
--- a/test/mobilizon_web/resolvers/event_resolver_test.exs
+++ b/test/mobilizon_web/resolvers/event_resolver_test.exs
@@ -322,5 +322,59 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
 
       assert hd(json_response(res, 200)["errors"])["message"] =~ "cannot delete"
     end
+
+    test "list_related_events/3 should give related events", %{
+      conn: conn,
+      actor: actor
+    } do
+      tag1 = insert(:tag, title: "Elixir", slug: "elixir")
+      tag2 = insert(:tag, title: "PostgreSQL", slug: "postgresql")
+
+      event = insert(:event, title: "Initial event", organizer_actor: actor, tags: [tag1, tag2])
+
+      event2 =
+        insert(:event,
+          title: "Event from same actor",
+          organizer_actor: actor,
+          visibility: :public,
+          begins_on: Timex.shift(DateTime.utc_now(), days: 3)
+        )
+
+      event3 =
+        insert(:event,
+          title: "Event with same tags",
+          tags: [tag1, tag2],
+          visibility: :public,
+          begins_on: Timex.shift(DateTime.utc_now(), days: 3)
+        )
+
+      query = """
+      {
+        event(uuid: "#{event.uuid}") {
+          uuid,
+          title,
+          tags {
+            id
+          },
+          related_events {
+            uuid,
+            title,
+            tags {
+              id
+            }
+          }
+        }
+      }
+      """
+
+      res =
+        conn
+        |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
+
+      assert hd(json_response(res, 200)["data"]["event"]["related_events"])["uuid"] == event2.uuid
+
+      assert hd(tl(json_response(res, 200)["data"]["event"]["related_events"]))["uuid"] ==
+               event3.uuid
+    end
   end
 end
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 324ec689f..a94e271ef 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -95,7 +95,7 @@ defmodule Mobilizon.Factory do
 
   def event_factory do
     actor = build(:actor)
-    start = Timex.now()
+    start = Timex.shift(DateTime.utc_now(), hours: 2)
     uuid = Ecto.UUID.generate()
 
     %Mobilizon.Events.Event{
@@ -108,6 +108,7 @@ defmodule Mobilizon.Factory do
       category: sequence("something"),
       physical_address: build(:address),
       visibility: :public,
+      tags: build_list(3, :tag),
       url: "#{actor.url}/#{uuid}",
       uuid: uuid
     }