diff --git a/LICENSE b/LICENSE index 8f584004..03493bbf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Podlove +Copyright (c) 2024 Podlove Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/radiator/outline/node.ex b/lib/radiator/outline/node.ex index 5f716919..a078b009 100644 --- a/lib/radiator/outline/node.ex +++ b/lib/radiator/outline/node.ex @@ -5,6 +5,7 @@ defmodule Radiator.Outline.Node do """ use Ecto.Schema import Ecto.Changeset + alias Radiator.Podcast.Episode @derive {Jason.Encoder, only: [:uuid, :content, :creator_id, :parent_id, :prev_id]} @@ -15,11 +16,14 @@ defmodule Radiator.Outline.Node do field :parent_id, Ecto.UUID field :prev_id, Ecto.UUID + belongs_to :episode, Episode + timestamps(type: :utc_datetime) end @required_fields [ - :content + :content, + :episode_id ] @optional_fields [ diff --git a/lib/radiator/podcast.ex b/lib/radiator/podcast.ex index b65ba4fc..77c21924 100644 --- a/lib/radiator/podcast.ex +++ b/lib/radiator/podcast.ex @@ -226,6 +226,27 @@ defmodule Radiator.Podcast do """ def get_episode!(id), do: Repo.get!(Episode, id) + @doc """ + Finds the newest (TODO: not published ) episode for a show. + Returns %Episode{} or `nil` and expects an id of the show. + + ## Examples + + iex> get_current_episode_for_show(123) + %Episode{} + + iex> get_current_episode_for_show(456) + nil + + """ + def get_current_episode_for_show(nil), do: nil + + def get_current_episode_for_show(show_id) do + Repo.one( + from e in Episode, where: e.show_id == ^show_id, order_by: [desc: e.number], limit: 1 + ) + end + @doc """ Creates a episode. @@ -239,11 +260,33 @@ defmodule Radiator.Podcast do """ def create_episode(attrs \\ %{}) do + attrs_with_number = set_number(attrs) + %Episode{} - |> Episode.changeset(attrs) + |> Episode.changeset(attrs_with_number) |> Repo.insert() end + defp set_number(%{number: _number} = attrs), do: attrs + + defp set_number(%{show_id: show_id} = episode_attrs) do + number = get_highest_number(show_id) + 1 + Map.put(episode_attrs, :number, number) + end + + defp set_number(%{} = episode_attrs) do + Map.put(episode_attrs, :number, 0) + end + + defp get_highest_number(show_id) do + query = + from e in Episode, + select: max(e.number), + where: [show_id: ^show_id] + + Repo.one(query) || 0 + end + @doc """ Updates a episode. diff --git a/lib/radiator/podcast/episode.ex b/lib/radiator/podcast/episode.ex index ca4c540a..ced456a4 100644 --- a/lib/radiator/podcast/episode.ex +++ b/lib/radiator/podcast/episode.ex @@ -1,7 +1,7 @@ defmodule Radiator.Podcast.Episode do @moduledoc """ Represents the Episode model. - TODO: Episodes should be numbered and ordered inside a show. + Episodes are numbered inside a show. """ use Ecto.Schema import Ecto.Changeset @@ -10,6 +10,7 @@ defmodule Radiator.Podcast.Episode do schema "episodes" do field :title, :string + field :number, :integer belongs_to :show, Show timestamps(type: :utc_datetime) end @@ -17,7 +18,7 @@ defmodule Radiator.Podcast.Episode do @doc false def changeset(episode, attrs) do episode - |> cast(attrs, [:title, :show_id]) - |> validate_required([:title, :show_id]) + |> cast(attrs, [:title, :show_id, :number]) + |> validate_required([:title, :show_id, :number]) end end diff --git a/lib/radiator_web/controllers/api/outline_controller.ex b/lib/radiator_web/controllers/api/outline_controller.ex index 19bdedb6..795d2d75 100644 --- a/lib/radiator_web/controllers/api/outline_controller.ex +++ b/lib/radiator_web/controllers/api/outline_controller.ex @@ -1,15 +1,16 @@ defmodule RadiatorWeb.Api.OutlineController do use RadiatorWeb, :controller - alias Radiator.Accounts - alias Radiator.Outline + alias Radiator.{Accounts, Outline, Podcast} + + def create(conn, %{"content" => content, "show_id" => show_id, "token" => token}) do + episode = Podcast.get_current_episode_for_show(show_id) - def create(conn, %{"content" => content, "token" => token}) do {status_code, body} = token |> decode_token() |> get_user_by_token() - |> create_node(content) + |> create_node(content, episode.id) |> get_response() conn @@ -28,8 +29,11 @@ defmodule RadiatorWeb.Api.OutlineController do defp get_user_by_token({:ok, token}), do: Accounts.get_user_by_api_token(token) defp get_user_by_token(:error), do: {:error, :token} - defp create_node(nil, _), do: {:error, :user} - defp create_node(user, content), do: Outline.create_node(%{"content" => content}, user) + defp create_node(nil, _, _), do: {:error, :user} + defp create_node(_, _, nil), do: {:error, :episode} + + defp create_node(user, content, episode_id), + do: Outline.create_node(%{"content" => content, "episode_id" => episode_id}, user) defp get_response({:ok, node}), do: {200, %{uuid: node.uuid}} defp get_response({:error, _}), do: {400, %{error: "params"}} diff --git a/priv/repo/migrations/20231216182723_add_outline_reference_to_episode.exs b/priv/repo/migrations/20231216182723_add_outline_reference_to_episode.exs new file mode 100644 index 00000000..590b27e8 --- /dev/null +++ b/priv/repo/migrations/20231216182723_add_outline_reference_to_episode.exs @@ -0,0 +1,9 @@ +defmodule Radiator.Repo.Migrations.AddOutlineReferenceToEpisode do + use Ecto.Migration + + def change do + alter table(:outline_nodes) do + add :episode_id, references(:episodes, on_delete: :nothing) + end + end +end diff --git a/priv/repo/migrations/20231223124852_add_number_to_episodes.exs b/priv/repo/migrations/20231223124852_add_number_to_episodes.exs new file mode 100644 index 00000000..cf52be3a --- /dev/null +++ b/priv/repo/migrations/20231223124852_add_number_to_episodes.exs @@ -0,0 +1,9 @@ +defmodule Radiator.Repo.Migrations.AddNumberToEpisodes do + use Ecto.Migration + + def change do + alter table(:episodes) do + add :number, :integer + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 5936a282..0e41d3d4 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -10,12 +10,6 @@ alias Radiator.{Accounts, Outline, Podcast} {:ok, _user_jim} = Accounts.register_user(%{email: "jim@radiator.de", password: "supersupersecret"}) -{:ok, _node} = - Outline.create_node(%{content: "This is my first node"}) - -{:ok, _node} = - Outline.create_node(%{content: "Second node"}) - {:ok, network} = Podcast.create_network(%{title: "Podcast network"}) @@ -28,5 +22,11 @@ alias Radiator.{Accounts, Outline, Podcast} {:ok, _episode} = Podcast.create_episode(%{title: "past episode", show_id: show.id}) -{:ok, _episode} = +{:ok, current_episode} = Podcast.create_episode(%{title: "current episode", show_id: show.id}) + +{:ok, _node} = + Outline.create_node(%{content: "This is my first node", episode_id: current_episode.id}) + +{:ok, _node} = + Outline.create_node(%{content: "Second node", episode_id: current_episode.id}) diff --git a/test/radiator/outline_test.exs b/test/radiator/outline_test.exs index 3b09125c..505364ef 100644 --- a/test/radiator/outline_test.exs +++ b/test/radiator/outline_test.exs @@ -7,6 +7,7 @@ defmodule Radiator.OutlineTest do alias Radiator.Outline.Node import Radiator.OutlineFixtures + alias Radiator.PodcastFixtures @invalid_attrs %{content: nil} @@ -21,22 +22,25 @@ defmodule Radiator.OutlineTest do end test "create_node/1 with valid data creates a node" do - valid_attrs = %{content: "some content"} + episode = PodcastFixtures.episode_fixture() + valid_attrs = %{content: "some content", episode_id: episode.id} assert {:ok, %Node{} = node} = Outline.create_node(valid_attrs) assert node.content == "some content" end test "create_node/1 trims whitespace from content" do - valid_attrs = %{content: " some content "} + episode = PodcastFixtures.episode_fixture() + valid_attrs = %{content: " some content ", episode_id: episode.id} assert {:ok, %Node{} = node} = Outline.create_node(valid_attrs) assert node.content == "some content" end test "create_node/1 can have a creator" do + episode = PodcastFixtures.episode_fixture() user = %{id: 2} - valid_attrs = %{content: "some content"} + valid_attrs = %{content: "some content", episode_id: episode.id} assert {:ok, %Node{} = node} = Outline.create_node(valid_attrs, user) assert node.content == "some content" diff --git a/test/radiator/podcast_test.exs b/test/radiator/podcast_test.exs index 57378371..80fc956e 100644 --- a/test/radiator/podcast_test.exs +++ b/test/radiator/podcast_test.exs @@ -132,6 +132,31 @@ defmodule Radiator.PodcastTest do assert episode.show_id == show.id end + test "create_episode/1 sets for first episode number 1" do + episode_attrs = %{title: "a new episode", show_id: show_fixture().id} + + {:ok, %Episode{} = episode} = Podcast.create_episode(episode_attrs) + assert episode.number > 0 + end + + test "create_episode/1 finds the next highest number " do + show = show_fixture() + episode_fixture(show_id: show.id, number: 23) + episode_attrs = %{title: "my new episode", show_id: show.id} + + {:ok, %Episode{} = episode} = Podcast.create_episode(episode_attrs) + assert episode.number == 24 + end + + test "create_episode/1 can be set explict" do + show = show_fixture() + episode_fixture(show_id: show.id, number: 2) + episode_attrs = %{title: "my new episode", number: 5, show_id: show.id} + + {:ok, %Episode{} = episode} = Podcast.create_episode(episode_attrs) + assert episode.number == 5 + end + test "create_episode/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{}} = Podcast.create_episode(@invalid_attrs) end @@ -162,5 +187,28 @@ defmodule Radiator.PodcastTest do episode = episode_fixture() assert %Ecto.Changeset{} = Podcast.change_episode(episode) end + + test "get_current_episode_for_show/1 returns nil when no show has been given" do + assert nil == Podcast.get_current_episode_for_show(nil) + end + + test "get_current_episode_for_show/1 returns nil when no episode for show exists" do + show = show_fixture() + assert nil == Podcast.get_current_episode_for_show(show.id) + end + + test "get_current_episode_for_show/1 returns episdoe for show" do + episode = episode_fixture() + assert episode == Podcast.get_current_episode_for_show(episode.show_id) + end + + test "get_current_episode_for_show/1 returns the episode with the highest number" do + show = show_fixture() + # create new before old to ensure that the highest number is returned + # and not just the newest + episode_new = episode_fixture(number: 23, show_id: show.id) + _episode_old = episode_fixture(number: 22, show_id: show.id) + assert episode_new == Podcast.get_current_episode_for_show(show.id) + end end end diff --git a/test/radiator_web/controllers/api/outline_controller_test.exs b/test/radiator_web/controllers/api/outline_controller_test.exs index 2203a25d..68b90eb1 100644 --- a/test/radiator_web/controllers/api/outline_controller_test.exs +++ b/test/radiator_web/controllers/api/outline_controller_test.exs @@ -2,29 +2,50 @@ defmodule RadiatorWeb.Api.OutlineControllerTest do use RadiatorWeb.ConnCase, async: true import Radiator.AccountsFixtures + import Radiator.PodcastFixtures - alias Radiator.Accounts - alias Radiator.Outline + alias Radiator.{Accounts, Outline, Podcast} describe "POST /api/v1/outline" do setup %{conn: conn} do user = user_fixture() + show_id = episode_fixture().show_id token = user |> Accounts.generate_user_api_token() |> Base.url_encode64() - %{conn: conn, user: user, token: token} + %{conn: conn, user: user, token: token, show_id: show_id} end - test "creates a node if content is present", %{conn: conn, user: %{id: user_id}, token: token} do - body = %{"content" => "new node content", "token" => token} + test "creates a node if content is present", %{ + conn: conn, + user: %{id: user_id}, + show_id: show_id, + token: token + } do + body = %{"content" => "new node content", "token" => token, show_id: show_id} conn = post(conn, ~p"/api/v1/outline", body) %{"uuid" => uuid} = json_response(conn, 200) - assert %{content: "new node content", creator_id: ^user_id} = Outline.get_node!(uuid) + assert %{content: "new node content", creator_id: ^user_id} = + Outline.get_node!(uuid) + end + + test "created node is connected to episode", %{ + conn: conn, + show_id: show_id, + token: token + } do + body = %{"content" => "new node content", "token" => token, show_id: show_id} + conn = post(conn, ~p"/api/v1/outline", body) + + %{"uuid" => uuid} = json_response(conn, 200) + + episode_id = Podcast.get_current_episode_for_show(show_id).id + assert %{content: "new node content", episode_id: ^episode_id} = Outline.get_node!(uuid) end test "can't create node when content is missing", %{conn: conn} do @@ -33,8 +54,11 @@ defmodule RadiatorWeb.Api.OutlineControllerTest do assert %{"error" => "missing params"} = json_response(conn, 400) end - test "can't create node when token is wrong", %{conn: conn} do - body = %{"content" => "new node content", "token" => "invalid"} + test "can't create node when token is wrong", %{ + conn: conn, + show_id: show_id + } do + body = %{"content" => "new node content", "token" => "invalid", show_id: show_id} conn = post(conn, ~p"/api/v1/outline", body) assert %{"error" => "params"} = json_response(conn, 400) diff --git a/test/support/fixtures/outline_fixtures.ex b/test/support/fixtures/outline_fixtures.ex index 6150a4bf..37257fb2 100644 --- a/test/support/fixtures/outline_fixtures.ex +++ b/test/support/fixtures/outline_fixtures.ex @@ -3,14 +3,20 @@ defmodule Radiator.OutlineFixtures do This module defines test helpers for creating entities via the `Radiator.Outline` context. """ + alias Radiator.PodcastFixtures @doc """ Generate a node. """ def node_fixture(attrs \\ %{}) do + episode = PodcastFixtures.episode_fixture() + {:ok, node} = attrs - |> Enum.into(%{content: "some content"}) + |> Enum.into(%{ + content: "some content", + episode_id: episode.id + }) |> Radiator.Outline.create_node() node