diff --git a/lib/radiator/event_store.ex b/lib/radiator/event_store.ex index 6db8d09d..157c35f2 100644 --- a/lib/radiator/event_store.ex +++ b/lib/radiator/event_store.ex @@ -3,8 +3,66 @@ defmodule Radiator.EventStore do EventStore persists events """ + alias Radiator.EventStore.EventData + alias Radiator.Outline.Event.AbstractEvent + alias Radiator.Repo + def persist_event(event) do - # persist event + {:ok, _stored_event} = + create_event_data(%{ + data: AbstractEvent.payload(event), + event_type: AbstractEvent.event_type(event), + uuid: event.event_id, + user_id: event.user_id + }) + event end + + @doc """ + Returns the list of foo_events. + + ## Examples + + iex> list_events() + [%Event{}, ...] + + """ + def list_event_data do + Repo.all(EventData) + end + + @doc """ + Gets a single event data. + + Raises `Ecto.NoResultsError` if the EventData does not exist. + + ## Examples + + iex> get_event_data!(123) + %Event{} + + iex> get_event_data!(456) + ** (Ecto.NoResultsError) + + """ + def get_event_data!(id), do: Repo.get!(EventData, id) + + @doc """ + Creates a event. + + ## Examples + + iex> create_event_data(%{field: value}) + {:ok, %EventData{}} + + iex> create_event_data(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_event_data(attrs \\ %{}) do + %EventData{} + |> EventData.changeset(attrs) + |> Repo.insert() + end end diff --git a/lib/radiator/event_store/event_data.ex b/lib/radiator/event_store/event_data.ex new file mode 100644 index 00000000..90286a5d --- /dev/null +++ b/lib/radiator/event_store/event_data.ex @@ -0,0 +1,25 @@ +defmodule Radiator.EventStore.EventData do + @moduledoc """ + EventData schema represents a persistend event. + """ + use Ecto.Schema + import Ecto.Changeset + + alias Radiator.Accounts.User + + @primary_key {:uuid, :binary_id, autogenerate: false} + schema "event_data" do + field :data, :map, default: %{} + field :event_type, :string + + belongs_to :user, User + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(event, attrs) do + event + |> cast(attrs, [:uuid, :event_type, :data, :user_id]) + |> validate_required([:uuid, :event_type, :user_id]) + end +end diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index e765565e..39a17c4e 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -91,7 +91,7 @@ defmodule Radiator.Outline do {:error, %Ecto.Changeset{}} """ - def remove_node(%Node{} = node, _socket_id \\ nil) do + def remove_node(%Node{} = node) do next_node = Node |> where([n], n.prev_id == ^node.uuid) diff --git a/lib/radiator/outline/command.ex b/lib/radiator/outline/command.ex index 89d8d474..c41dacaf 100644 --- a/lib/radiator/outline/command.ex +++ b/lib/radiator/outline/command.ex @@ -1,7 +1,12 @@ defmodule Radiator.Outline.Command do @moduledoc false - alias Radiator.Outline.Command.{ChangeNodeContentCommand, InsertNodeCommand} + alias Radiator.Outline.Command.{ + ChangeNodeContentCommand, + DeleteNodeCommand, + InsertNodeCommand, + MoveNodeCommand + } def build("insert_node", payload, user_id, event_id) do %InsertNodeCommand{ @@ -11,6 +16,14 @@ defmodule Radiator.Outline.Command do } end + def build("delete_node", node_id, user_id, event_id) do + %DeleteNodeCommand{ + event_id: event_id, + user_id: user_id, + node_id: node_id + } + end + def build("change_node_content", node_id, content, user_id, event_id) do %ChangeNodeContentCommand{ event_id: event_id, @@ -19,4 +32,14 @@ defmodule Radiator.Outline.Command do content: content } end + + def build("move_node", node_id, parent_node_id, prev_node_id, user_id, event_id) do + %MoveNodeCommand{ + event_id: event_id, + user_id: user_id, + node_id: node_id, + parent_node_id: parent_node_id, + prev_node_id: prev_node_id + } + end end diff --git a/lib/radiator/outline/dispatch.ex b/lib/radiator/outline/dispatch.ex index 97e78219..59d5f1f0 100644 --- a/lib/radiator/outline/dispatch.ex +++ b/lib/radiator/outline/dispatch.ex @@ -11,23 +11,22 @@ defmodule Radiator.Outline.Dispatch do end def change_node_content(node_id, content, user_id, event_id) do - # IO.inspect(node_id, label: "Dispatcher change_node_content") "change_node_content" |> Command.build(node_id, content, user_id, event_id) |> EventProducer.enqueue() end - # def move_node(attributes, user_id, event_id) do - # "move_node" - # |> Command.build(attributes, user_id, event_id) - # |> EventProducer.enqueue() - # end - - # def delete_node(node_id, user_id, event_id) do - # "delete_node" - # |> Command.build(node_id, user_id, event_id) - # |> EventProducer.enqueue() - # end + def move_node(node_id, parent_node_id, prev_node_id, user_id, event_id) do + "move_node" + |> Command.build(node_id, parent_node_id, prev_node_id, user_id, event_id) + |> EventProducer.enqueue() + end + + def delete_node(node_id, user_id, event_id) do + "delete_node" + |> Command.build(node_id, user_id, event_id) + |> EventProducer.enqueue() + end def subscribe(_episode_id) do Phoenix.PubSub.subscribe(Radiator.PubSub, "events") diff --git a/lib/radiator/outline/event/abstract_event.ex b/lib/radiator/outline/event/abstract_event.ex new file mode 100644 index 00000000..b57f4c42 --- /dev/null +++ b/lib/radiator/outline/event/abstract_event.ex @@ -0,0 +1,38 @@ +defprotocol Radiator.Outline.Event.AbstractEvent do + def payload(event) + def event_type(event) +end + +alias Radiator.Outline.Event.{NodeContentChangedEvent, NodeInsertedEvent} + +defimpl Radiator.Outline.Event.AbstractEvent, for: NodeInsertedEvent do + def payload(event) do + event.node + end + + def event_type(_event), do: "NodeInsertedEvent" +end + +defimpl Radiator.Outline.Event.AbstractEvent, for: NodeContentChangedEvent do + def payload(event) do + %{node_id: event.node_id, content: event.content} + end + + def event_type(_event), do: "NodeInsertedEvent" +end + +defimpl Radiator.Outline.Event.AbstractEvent, for: NodeDeletedEvent do + def payload(event) do + event.node_id + end + + def event_type(_event), do: "NodeDeletedEvent" +end + +defimpl Radiator.Outline.Event.AbstractEvent, for: NodeMovedEvent do + def payload(event) do + %{node_id: event.node_id, parent_id: event.parent_id, prev_id: event.prev_id} + end + + def event_type(_event), do: "NodeInsertedEvent" +end diff --git a/lib/radiator/outline/event/node_content_changed_event.ex b/lib/radiator/outline/event/node_content_changed_event.ex index 7059eb71..5c3b027b 100644 --- a/lib/radiator/outline/event/node_content_changed_event.ex +++ b/lib/radiator/outline/event/node_content_changed_event.ex @@ -1,5 +1,5 @@ defmodule Radiator.Outline.Event.NodeContentChangedEvent do @moduledoc false - defstruct [:event_id, :node] + defstruct [:event_id, :node_id, :content, :user_id] end diff --git a/lib/radiator/outline/event/node_deleted_event.ex b/lib/radiator/outline/event/node_deleted_event.ex index 9eaa521a..78e4ed8c 100644 --- a/lib/radiator/outline/event/node_deleted_event.ex +++ b/lib/radiator/outline/event/node_deleted_event.ex @@ -1,3 +1,4 @@ defmodule Radiator.Outline.Event.NodeDeletedEvent do @moduledoc false + defstruct [:event_id, :node_id, :user_id] end diff --git a/lib/radiator/outline/event/node_inserted_event.ex b/lib/radiator/outline/event/node_inserted_event.ex index b8569087..78e2a5a4 100644 --- a/lib/radiator/outline/event/node_inserted_event.ex +++ b/lib/radiator/outline/event/node_inserted_event.ex @@ -1,5 +1,5 @@ defmodule Radiator.Outline.Event.NodeInsertedEvent do @moduledoc false - defstruct [:event_id, :node] + defstruct [:event_id, :node, :user_id] end diff --git a/lib/radiator/outline/event/node_moved_event.ex b/lib/radiator/outline/event/node_moved_event.ex index 797a0c2c..41c6abe8 100644 --- a/lib/radiator/outline/event/node_moved_event.ex +++ b/lib/radiator/outline/event/node_moved_event.ex @@ -1,3 +1,4 @@ defmodule Radiator.Outline.Event.NodeMovedEvent do @moduledoc false + defstruct [:event_id, :node_id, :parent_id, :prev_id, :user_id] end diff --git a/lib/radiator/outline/event_consumer.ex b/lib/radiator/outline/event_consumer.ex index ddfcb710..8b2c345e 100644 --- a/lib/radiator/outline/event_consumer.ex +++ b/lib/radiator/outline/event_consumer.ex @@ -24,8 +24,9 @@ defmodule Radiator.Outline.EventConsumer do {:noreply, [], state} end - defp process_command(%InsertNodeCommand{payload: payload} = command) do + defp process_command(%InsertNodeCommand{payload: payload, user_id: user_id} = command) do payload + |> Map.merge(%{"user_id" => user_id}) |> Outline.insert_node() |> handle_insert_node_result(command) end @@ -37,7 +38,7 @@ defmodule Radiator.Outline.EventConsumer do end defp handle_insert_node_result({:ok, node}, command) do - %NodeInsertedEvent{node: node, event_id: command.event_id} + %NodeInsertedEvent{node: node, event_id: command.event_id, user_id: command.user_id} |> EventStore.persist_event() |> Dispatch.broadcast() @@ -49,8 +50,13 @@ defmodule Radiator.Outline.EventConsumer do :error end - def handle_change_node_content_result({:ok, node}, command) do - %NodeContentChangedEvent{node: node, event_id: command.event_id} + def handle_change_node_content_result({:ok, node}, %ChangeNodeContentCommand{} = command) do + %NodeContentChangedEvent{ + node_id: node.id, + content: node.content, + user_id: command.user_id, + event_id: command.event_id + } |> EventStore.persist_event() |> Dispatch.broadcast() diff --git a/lib/radiator/outline/node.ex b/lib/radiator/outline/node.ex index c6fc1008..a5125fdc 100644 --- a/lib/radiator/outline/node.ex +++ b/lib/radiator/outline/node.ex @@ -1,7 +1,6 @@ defmodule Radiator.Outline.Node do @moduledoc """ - The node model which represents a single node in the outline. - Currenty there is no concept of a tree + The node model represents a single node in the outline. """ use Ecto.Schema import Ecto.Changeset diff --git a/lib/radiator_web/live/episode_live/index.ex b/lib/radiator_web/live/episode_live/index.ex index 9e077836..2bbcd8d5 100644 --- a/lib/radiator_web/live/episode_live/index.ex +++ b/lib/radiator_web/live/episode_live/index.ex @@ -1,7 +1,6 @@ defmodule RadiatorWeb.EpisodeLive.Index do use RadiatorWeb, :live_view - alias Radiator.Outline alias Radiator.Outline.{Dispatch, NodeRepository} alias Radiator.Outline.Event.{NodeContentChangedEvent, NodeInsertedEvent} alias Radiator.Podcast @@ -67,13 +66,13 @@ defmodule RadiatorWeb.EpisodeLive.Index do |> reply(:noreply) end - def handle_event("delete_node", %{"uuid" => uuid}, socket) do + def handle_event("delete_node", %{"uuid" => _uuid}, socket) do _event_id = generate_event_id(socket.id) - case NodeRepository.get_node(uuid) do - nil -> nil - node -> Outline.remove_node(node, socket.id) - end + # case NodeRepository.get_node(uuid) do + # nil -> nil + # node -> Outline.remove_node(node, socket.id) + # end socket |> reply(:noreply) @@ -95,9 +94,12 @@ defmodule RadiatorWeb.EpisodeLive.Index do |> reply(:noreply) end - def handle_info(%NodeContentChangedEvent{node: node}, socket) do + def handle_info( + %NodeContentChangedEvent{node_id: id, content: content}, + socket + ) do socket - |> push_event("update", %{node: node}) + |> push_event("update", %{node: %{id: id, content: content}}) |> reply(:noreply) end diff --git a/priv/repo/migrations/20240501153929_create_event_data.exs b/priv/repo/migrations/20240501153929_create_event_data.exs new file mode 100644 index 00000000..5329fd5a --- /dev/null +++ b/priv/repo/migrations/20240501153929_create_event_data.exs @@ -0,0 +1,16 @@ +defmodule Radiator.Repo.Migrations.CreateEventData do + use Ecto.Migration + + def change do + create table(:event_data, primary_key: false) do + add :uuid, :uuid, primary_key: true + add :event_type, :string + add :user_id, references(:users, on_delete: :nothing) + add :data, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:event_data, [:user_id]) + end +end diff --git a/test/radiator/event_store_test.exs b/test/radiator/event_store_test.exs new file mode 100644 index 00000000..bfc28f64 --- /dev/null +++ b/test/radiator/event_store_test.exs @@ -0,0 +1,45 @@ +defmodule Radiator.EventStoreTest do + use Radiator.DataCase + + alias Radiator.EventStore + + describe "event_data" do + alias Radiator.EventStore.EventData + + alias Radiator.AccountsFixtures + import Radiator.EventStoreFixtures + + @invalid_attrs %{data: nil, uuid: nil, event_type: nil} + + test "list_event_data/0 returns all event_data" do + event = event_data_fixture() + assert EventStore.list_event_data() == [event] + end + + test "get_event!/1 returns the event_data with given id" do + event = event_data_fixture() + assert EventStore.get_event_data!(event.uuid) == event + end + + test "create_event/1 with valid data creates a event" do + user = AccountsFixtures.user_fixture() + + valid_attrs = %{ + data: %{}, + uuid: Ecto.UUID.generate(), + event_type: "some event_type", + user_id: user.id + } + + assert {:ok, %EventData{} = event} = EventStore.create_event_data(valid_attrs) + assert event.data == %{} + assert event.uuid == valid_attrs.uuid + assert event.event_type == valid_attrs.event_type + assert event.user_id == valid_attrs.user_id + end + + test "create_event/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = EventStore.create_event_data(@invalid_attrs) + end + end +end diff --git a/test/radiator/outline/event_consumer_test.exs b/test/radiator/outline/event_consumer_test.exs index 5324e8b8..84b8500f 100644 --- a/test/radiator/outline/event_consumer_test.exs +++ b/test/radiator/outline/event_consumer_test.exs @@ -1,40 +1,56 @@ defmodule Radiator.Outline.EventConsumerTest do + alias Radiator.Outline.NodeRepository use Radiator.DataCase alias Radiator.AccountsFixtures - alias Radiator.Outline.Command + alias Radiator.EventStore + alias Radiator.Outline.{Command, Dispatch, EventConsumer, EventProducer, NodeRepository} alias Radiator.Outline.Command.InsertNodeCommand - alias Radiator.Outline.Dispatch alias Radiator.Outline.Event.NodeInsertedEvent - alias Radiator.Outline.EventConsumer - alias Radiator.Outline.EventProducer alias Radiator.PodcastFixtures describe "handle_events/2" do - test "insert_node" do - episode = PodcastFixtures.episode_fixture() + setup :prepare_outline + test "insert_node stores a node", %{episode: episode, user: user, event_id: event_id} do attributes = %{ "title" => "Node Title", "content" => "Node Content", "episode_id" => episode.id } - user_id = "user_id" - event_id = "event_id" - - command = Command.build("insert_node", attributes, user_id, event_id) + num_nodes = NodeRepository.count_nodes_by_episode(episode.id) + command = Command.build("insert_node", attributes, user.id, event_id) EventConsumer.handle_events([command], 0, nil) + # assert a node has been created - # assert an event has been created (and be stored) + assert num_nodes + 1 == NodeRepository.count_nodes_by_episode(episode.id) end - test "handles previously enqueued events" do - producer = start_supervised!({EventProducer, name: TestEventProducer}) + test "insert_node creates and stores an event", %{ + episode: episode, + user: user, + event_id: event_id + } do + new_content = "Node Content" + + attributes = %{ + "title" => "Node Title", + "content" => new_content, + "episode_id" => episode.id + } - episode = PodcastFixtures.episode_fixture() - user = AccountsFixtures.user_fixture() - event_id = Ecto.UUID.generate() + command = Command.build("insert_node", attributes, user.id, event_id) + EventConsumer.handle_events([command], 0, nil) + event = EventStore.list_event_data() |> hd() + + assert event.event_type == "NodeInsertedEvent" + assert event.user_id == user.id + assert event.data["content"] == new_content + end + + test "handles previously enqueued events", %{episode: episode, user: user, event_id: event_id} do + producer = start_supervised!({EventProducer, name: TestEventProducer}) command = %InsertNodeCommand{ event_id: event_id, @@ -47,7 +63,6 @@ defmodule Radiator.Outline.EventConsumerTest do } Dispatch.subscribe(episode.id) - EventProducer.enqueue(producer, command) start_supervised!( @@ -57,4 +72,11 @@ defmodule Radiator.Outline.EventConsumerTest do assert_receive(%NodeInsertedEvent{}, 1000) end end + + def prepare_outline(_) do + episode = PodcastFixtures.episode_fixture() + user = AccountsFixtures.user_fixture() + event_id = Ecto.UUID.generate() + %{episode: episode, user: user, event_id: event_id} + end end diff --git a/test/support/fixtures/event_store_fixtures.ex b/test/support/fixtures/event_store_fixtures.ex new file mode 100644 index 00000000..eac8d4d9 --- /dev/null +++ b/test/support/fixtures/event_store_fixtures.ex @@ -0,0 +1,26 @@ +defmodule Radiator.EventStoreFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Radiator.EventStore` context. + """ + alias Radiator.AccountsFixtures + + @doc """ + Generate a event data. + """ + def event_data_fixture(attrs \\ %{}) do + user = AccountsFixtures.user_fixture() + + {:ok, event} = + attrs + |> Enum.into(%{ + data: %{}, + event_type: "some event_type", + uuid: Ecto.UUID.generate(), + user_id: user.id + }) + |> Radiator.EventStore.create_event_data() + + event + end +end