diff --git a/lib/mobilizon/posts/post.ex b/lib/mobilizon/posts/post.ex index adf95598..a2028ccd 100644 --- a/lib/mobilizon/posts/post.ex +++ b/lib/mobilizon/posts/post.ex @@ -96,6 +96,7 @@ defmodule Mobilizon.Posts.Post do |> TitleSlug.unique_constraint() |> maybe_generate_url() |> validate_required(@required_attrs -- [:slug, :url]) + |> unique_constraint(:url) end defp maybe_generate_id(%Ecto.Changeset{} = changeset) do diff --git a/priv/repo/migrations/20210622133516_cleanup_posts.exs b/priv/repo/migrations/20210622133516_cleanup_posts.exs new file mode 100644 index 00000000..80831acf --- /dev/null +++ b/priv/repo/migrations/20210622133516_cleanup_posts.exs @@ -0,0 +1,73 @@ +defmodule Mobilizon.Storage.Repo.Migrations.CleanupPosts do + use Ecto.Migration + + def up do + # Make sure we don't have any duplicate posts + rows = fetch_bad_rows() + Enum.each(rows, &process_row/1) + end + + def down do + # No way down + end + + defp fetch_bad_rows() do + %Postgrex.Result{rows: rows} = + Ecto.Adapters.SQL.query!( + Mobilizon.Storage.Repo, + "SELECT * FROM ( + SELECT id, url, + ROW_NUMBER() OVER(PARTITION BY url ORDER BY id asc) AS Row + FROM posts + ) dups + WHERE dups.Row > 1;" + ) + + rows + end + + defp process_row([id, url, _row]) do + first_id = find_first_post_id(url) + + if id != first_id do + repair_post_medias(id, first_id) + repair_post_tags(id, first_id) + delete_row(id) + end + end + + defp find_first_post_id(url) do + %Postgrex.Result{rows: [[id]]} = + Ecto.Adapters.SQL.query!( + Mobilizon.Storage.Repo, + "SELECT id FROM posts WHERE url = $1 order by inserted_at asc limit 1", + [url] + ) + + id + end + + defp repair_post_medias(id, first_id) do + Ecto.Adapters.SQL.query!( + Mobilizon.Storage.Repo, + "UPDATE post_medias SET post_id = $1 WHERE post_id = $2", + [first_id, id] + ) + end + + defp repair_post_tags(id, first_id) do + Ecto.Adapters.SQL.query!( + Mobilizon.Storage.Repo, + "UPDATE post_tags SET post_id = $1 WHERE post_id = $2", + [first_id, id] + ) + end + + defp delete_row(id) do + Ecto.Adapters.SQL.query!( + Mobilizon.Storage.Repo, + "DELETE FROM posts WHERE id = $1", + [id] + ) + end +end diff --git a/priv/repo/migrations/20210622133555_add_index_to_posts.exs b/priv/repo/migrations/20210622133555_add_index_to_posts.exs new file mode 100644 index 00000000..8fecd97c --- /dev/null +++ b/priv/repo/migrations/20210622133555_add_index_to_posts.exs @@ -0,0 +1,11 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddIndexToPosts do + use Ecto.Migration + + def up do + create_if_not_exists(unique_index("posts", [:url])) + end + + def down do + drop_if_exists(index("posts", [:url])) + end +end diff --git a/test/mobilizon/posts_test.exs b/test/mobilizon/posts_test.exs index 4f2b71cc..f9738366 100644 --- a/test/mobilizon/posts_test.exs +++ b/test/mobilizon/posts_test.exs @@ -42,6 +42,29 @@ defmodule Mobilizon.PostsTest do end end + test "create_post/1 with an already existing URL returns error changeset" do + %Actor{} = actor = insert(:actor) + %Actor{} = group = insert(:group) + + post_data = + Map.merge(@valid_attrs, %{ + author_id: actor.id, + attributed_to_id: group.id, + url: "https://remote.tld/p/post" + }) + + {:ok, %Post{} = _post} = Posts.create_post(post_data) + + assert {:error, + %Ecto.Changeset{ + errors: [ + url: + {"has already been taken", + [constraint: :unique, constraint_name: "posts_url_index"]} + ] + }} = Posts.create_post(post_data) + end + test "create_post/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{}} = Posts.create_post(@invalid_attrs) end