From 13ac44679397ca4fed3c9d18ff329f798c596223 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 23 Apr 2026 12:21:13 +0200 Subject: [PATCH] feat: first entities in database --- lib/bds/posts.ex | 172 +++++++++++++++++++++++++++++++++++ lib/bds/posts/post.ex | 74 +++++++++++++++ lib/bds/projects.ex | 84 +++++++++++++++++ lib/bds/projects/project.ex | 30 ++++++ lib/bds/slug.ex | 23 +++++ lib/bds/types/string_list.ex | 45 +++++++++ mix.exs | 3 +- mix.lock | 1 + test/bds/posts_test.exs | 97 ++++++++++++++++++++ test/bds/projects_test.exs | 40 ++++++++ 10 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 lib/bds/posts.ex create mode 100644 lib/bds/posts/post.ex create mode 100644 lib/bds/projects.ex create mode 100644 lib/bds/projects/project.ex create mode 100644 lib/bds/slug.ex create mode 100644 lib/bds/types/string_list.ex create mode 100644 test/bds/posts_test.exs create mode 100644 test/bds/projects_test.exs diff --git a/lib/bds/posts.ex b/lib/bds/posts.ex new file mode 100644 index 0000000..8286183 --- /dev/null +++ b/lib/bds/posts.ex @@ -0,0 +1,172 @@ +defmodule BDS.Posts do + @moduledoc false + + import Ecto.Query + + alias BDS.Posts.Post + alias BDS.Repo + alias BDS.Slug + + def create_post(attrs) do + now = System.system_time(:second) + project_id = attr(attrs, :project_id) + title = normalize_title(attr(attrs, :title)) + base_slug = title |> default_slug_source() |> Slug.slugify() + + %Post{} + |> Post.changeset(%{ + id: Ecto.UUID.generate(), + project_id: project_id, + title: title, + slug: unique_slug(project_id, base_slug), + excerpt: attr(attrs, :excerpt), + content: attr(attrs, :content), + status: :draft, + author: attr(attrs, :author), + created_at: now, + updated_at: now, + published_at: nil, + file_path: "", + checksum: attr(attrs, :checksum), + tags: attr(attrs, :tags) || [], + categories: attr(attrs, :categories) || [], + template_slug: attr(attrs, :template_slug), + language: attr(attrs, :language), + do_not_translate: false, + published_title: nil, + published_content: nil, + published_tags: nil, + published_categories: nil, + published_excerpt: nil + }) + |> Repo.insert() + end + + def update_post(post_id, attrs) do + case Repo.get(Post, post_id) do + nil -> + {:error, :not_found} + + post -> + with :ok <- validate_slug_change(post, attrs) do + now = System.system_time(:second) + updates = + attrs + |> normalize_updates(post) + |> Map.put(:updated_at, now) + |> maybe_reopen_published_post(post) + + post + |> Post.changeset(updates) + |> Repo.update() + else + {:error, changeset} -> {:error, changeset} + end + end + end + + defp normalize_updates(attrs, _post) do + %{} + |> maybe_put(:title, normalize_optional_title(attr(attrs, :title), attrs)) + |> maybe_put(:slug, attr(attrs, :slug)) + |> maybe_put(:excerpt, attr(attrs, :excerpt)) + |> maybe_put(:content, attr(attrs, :content)) + |> maybe_put(:status, attr(attrs, :status)) + |> maybe_put(:author, attr(attrs, :author)) + |> maybe_put(:published_at, attr(attrs, :published_at)) + |> maybe_put(:file_path, attr(attrs, :file_path)) + |> maybe_put(:checksum, attr(attrs, :checksum)) + |> maybe_put(:tags, attr(attrs, :tags)) + |> maybe_put(:categories, attr(attrs, :categories)) + |> maybe_put(:template_slug, attr(attrs, :template_slug)) + |> maybe_put(:language, attr(attrs, :language)) + |> maybe_put(:do_not_translate, attr(attrs, :do_not_translate)) + |> maybe_put(:published_title, attr(attrs, :published_title)) + |> maybe_put(:published_content, attr(attrs, :published_content)) + |> maybe_put(:published_tags, attr(attrs, :published_tags)) + |> maybe_put(:published_categories, attr(attrs, :published_categories)) + |> maybe_put(:published_excerpt, attr(attrs, :published_excerpt)) + end + + defp validate_slug_change(%Post{published_at: published_at} = post, attrs) when not is_nil(published_at) do + case attr(attrs, :slug) do + nil -> + :ok + + slug when slug == post.slug -> + :ok + + _slug -> + {:error, + post + |> Post.changeset(%{}) + |> Ecto.Changeset.add_error(:slug, "cannot change slug after first publish")} + end + end + + defp validate_slug_change(_post, _attrs), do: :ok + + defp maybe_reopen_published_post(updates, %Post{status: :published} = post) do + if published_content_change?(updates, post) do + Map.put(updates, :status, :draft) + else + updates + end + end + + defp maybe_reopen_published_post(updates, _post), do: updates + + defp published_content_change?(updates, post) do + Enum.any?([:title, :excerpt, :content, :author, :language, :template_slug, :tags, :categories, :do_not_translate], fn field -> + case Map.fetch(updates, field) do + {:ok, value} -> value != Map.get(post, field) + :error -> false + end + end) + end + + defp unique_slug(project_id, base_slug) do + normalized = if base_slug in [nil, ""], do: "untitled", else: base_slug + + if slug_available?(project_id, normalized) do + normalized + else + find_unique_slug(project_id, normalized, 2) + end + end + + defp find_unique_slug(project_id, base_slug, suffix) do + candidate = "#{base_slug}-#{suffix}" + + if slug_available?(project_id, candidate) do + candidate + else + find_unique_slug(project_id, base_slug, suffix + 1) + end + end + + defp slug_available?(project_id, slug) do + not Repo.exists?(from post in Post, where: post.project_id == ^project_id and post.slug == ^slug) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp normalize_title(nil), do: "" + defp normalize_title(title), do: title + + defp normalize_optional_title(_title, attrs) do + if has_attr?(attrs, :title), do: normalize_title(attr(attrs, :title)), else: nil + end + + defp default_slug_source(""), do: "untitled" + defp default_slug_source(title), do: title + + defp has_attr?(attrs, key) do + Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) + end + + defp attr(attrs, key) do + Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + end +end diff --git a/lib/bds/posts/post.ex b/lib/bds/posts/post.ex new file mode 100644 index 0000000..9b0f2f7 --- /dev/null +++ b/lib/bds/posts/post.ex @@ -0,0 +1,74 @@ +defmodule BDS.Posts.Post do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + alias BDS.Types.StringList + + @primary_key {:id, :string, autogenerate: false} + @foreign_key_type :string + @statuses [:draft, :published, :archived] + + schema "posts" do + belongs_to :project, BDS.Projects.Project, type: :string + + field :title, :string + field :slug, :string + field :excerpt, :string + field :content, :string + field :status, Ecto.Enum, values: @statuses, default: :draft + field :author, :string + field :created_at, :integer + field :updated_at, :integer + field :published_at, :integer + field :file_path, :string, default: "" + field :checksum, :string + field :tags, StringList, default: [] + field :categories, StringList, default: [] + field :template_slug, :string + field :language, :string + field :do_not_translate, :boolean, default: false + field :published_title, :string + field :published_content, :string + field :published_tags, :string + field :published_categories, :string + field :published_excerpt, :string + end + + def changeset(post, attrs) do + post + |> cast( + attrs, + [ + :id, + :project_id, + :title, + :slug, + :excerpt, + :content, + :status, + :author, + :created_at, + :updated_at, + :published_at, + :file_path, + :checksum, + :tags, + :categories, + :template_slug, + :language, + :do_not_translate, + :published_title, + :published_content, + :published_tags, + :published_categories, + :published_excerpt + ], + empty_values: [nil] + ) + |> validate_required([:id, :project_id, :slug, :status, :created_at, :updated_at, :do_not_translate]) + |> assoc_constraint(:project) + |> unique_constraint(:slug, name: :posts_project_slug_idx) + end +end diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex new file mode 100644 index 0000000..9d3efac --- /dev/null +++ b/lib/bds/projects.ex @@ -0,0 +1,84 @@ +defmodule BDS.Projects do + @moduledoc false + + import Ecto.Query + + alias Ecto.Multi + alias BDS.Projects.Project + alias BDS.Repo + alias BDS.Slug + + def list_projects do + Repo.all(from project in Project, order_by: [asc: project.created_at]) + end + + def get_project!(id), do: Repo.get!(Project, id) + + def create_project(attrs) do + now = System.system_time(:second) + name = attr(attrs, :name) || "" + slug = unique_slug(attr(attrs, :slug) || Slug.slugify(name)) + + %Project{} + |> Project.changeset(%{ + id: Ecto.UUID.generate(), + name: name, + slug: slug, + description: attr(attrs, :description), + data_path: attr(attrs, :data_path), + created_at: now, + updated_at: now, + is_active: false + }) + |> Repo.insert() + end + + def set_active_project(project_id) do + case Repo.get(Project, project_id) do + nil -> + {:error, :not_found} + + project -> + now = System.system_time(:second) + + Multi.new() + |> Multi.update_all(:clear_previous, from(p in Project, where: p.is_active == true), + set: [is_active: false, updated_at: now] + ) + |> Multi.update(:activate, Project.changeset(project, %{is_active: true, updated_at: now})) + |> Repo.transaction() + |> case do + {:ok, %{activate: active_project}} -> {:ok, active_project} + {:error, _step, reason, _changes} -> {:error, reason} + end + end + end + + defp unique_slug(base_slug) do + normalized = if base_slug in [nil, ""], do: "project", else: base_slug + + if slug_available?(normalized) do + normalized + else + find_unique_slug(normalized, 2) + end + end + + defp find_unique_slug(base_slug, suffix) do + candidate = "#{base_slug}-#{suffix}" + + if slug_available?(candidate) do + candidate + else + find_unique_slug(base_slug, suffix + 1) + end + end + + defp slug_available?(slug) do + not Repo.exists?(from project in Project, where: project.slug == ^slug) + end + + defp attr(attrs, key) do + Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + end +end diff --git a/lib/bds/projects/project.ex b/lib/bds/projects/project.ex new file mode 100644 index 0000000..e547162 --- /dev/null +++ b/lib/bds/projects/project.ex @@ -0,0 +1,30 @@ +defmodule BDS.Projects.Project do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :string, autogenerate: false} + @foreign_key_type :string + + schema "projects" do + field :name, :string + field :slug, :string + field :description, :string + field :data_path, :string + field :created_at, :integer + field :updated_at, :integer + field :is_active, :boolean, default: false + + has_many :posts, BDS.Posts.Post, foreign_key: :project_id + end + + def changeset(project, attrs) do + project + |> cast(attrs, [:id, :name, :slug, :description, :data_path, :created_at, :updated_at, :is_active], + empty_values: [nil] + ) + |> validate_required([:id, :name, :slug, :created_at, :updated_at, :is_active]) + |> unique_constraint(:slug) + end +end diff --git a/lib/bds/slug.ex b/lib/bds/slug.ex new file mode 100644 index 0000000..86f8d2e --- /dev/null +++ b/lib/bds/slug.ex @@ -0,0 +1,23 @@ +defmodule BDS.Slug do + @moduledoc false + + @german_transliterations %{"ß" => "ss"} + + def slugify(nil), do: "" + + def slugify(value) when is_binary(value) do + value + |> replace_german_characters() + |> String.normalize(:nfd) + |> String.replace(~r/[^\p{ASCII}]/u, "") + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/u, "-") + |> String.replace(~r/^-+|-+$/u, "") + end + + defp replace_german_characters(value) do + Enum.reduce(@german_transliterations, value, fn {source, target}, acc -> + String.replace(acc, source, target) + end) + end +end diff --git a/lib/bds/types/string_list.ex b/lib/bds/types/string_list.ex new file mode 100644 index 0000000..0aae140 --- /dev/null +++ b/lib/bds/types/string_list.ex @@ -0,0 +1,45 @@ +defmodule BDS.Types.StringList do + @moduledoc false + + use Ecto.Type + + def type, do: :string + + def cast(value) when is_list(value) do + if Enum.all?(value, &is_binary/1) do + {:ok, value} + else + :error + end + end + + def cast(nil), do: {:ok, []} + def cast(_value), do: :error + + def load(nil), do: {:ok, []} + + def load(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> + if is_list(decoded) and Enum.all?(decoded, &is_binary/1) do + {:ok, decoded} + else + :error + end + + _ -> :error + end + end + + def dump(nil), do: {:ok, "[]"} + + def dump(value) when is_list(value) do + if Enum.all?(value, &is_binary/1) do + {:ok, Jason.encode!(value)} + else + :error + end + end + + def dump(_value), do: :error +end diff --git a/mix.exs b/mix.exs index 076ee53..c544dfa 100644 --- a/mix.exs +++ b/mix.exs @@ -23,7 +23,8 @@ defmodule BDS.MixProject do [ {:ecto_sql, "~> 3.13"}, {:ecto_sqlite3, "~> 0.21"}, - {:luerl, "~> 1.5"} + {:luerl, "~> 1.5"}, + {:jason, "~> 1.4"} ] end diff --git a/mix.lock b/mix.lock index bf72e11..e6fe8f5 100644 --- a/mix.lock +++ b/mix.lock @@ -7,6 +7,7 @@ "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, } diff --git a/test/bds/posts_test.exs b/test/bds/posts_test.exs new file mode 100644 index 0000000..44a9391 --- /dev/null +++ b/test/bds/posts_test.exs @@ -0,0 +1,97 @@ +defmodule BDS.PostsTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + {:ok, project} = BDS.Projects.create_project(%{name: "Publishing"}) + %{project: project} + end + + test "create_post slugifies titles, stores list fields, and defaults draft fields", %{project: project} do + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Über Café", + content: "draft body", + tags: ["elixir", "sqlite"], + categories: ["notes"], + author: "G", + language: "de", + template_slug: "article" + }) + + assert post.title == "Über Café" + assert post.slug == "uber-cafe" + assert post.status == :draft + assert post.file_path == "" + assert post.do_not_translate == false + assert post.tags == ["elixir", "sqlite"] + assert post.categories == ["notes"] + assert post.author == "G" + assert post.language == "de" + assert post.template_slug == "article" + assert post.project_id == project.id + assert is_integer(post.created_at) + assert is_integer(post.updated_at) + + assert {:ok, duplicate_slug_post} = + BDS.Posts.create_post(%{project_id: project.id, title: "Über Café"}) + + assert duplicate_slug_post.slug == "uber-cafe-2" + assert duplicate_slug_post.tags == [] + assert duplicate_slug_post.categories == [] + end + + test "create_post falls back to untitled and keeps slug uniqueness scoped to a project", %{project: project} do + assert {:ok, first} = BDS.Posts.create_post(%{project_id: project.id, title: nil}) + assert first.title == "" + assert first.slug == "untitled" + + assert {:ok, second} = BDS.Posts.create_post(%{project_id: project.id, title: nil}) + assert second.slug == "untitled-2" + + assert {:ok, other_project} = BDS.Projects.create_project(%{name: "Elsewhere"}) + assert {:ok, other_post} = BDS.Posts.create_post(%{project_id: other_project.id, title: nil}) + assert other_post.slug == "untitled" + end + + test "update_post rejects slug changes after first publish and reopens published posts when content changes", %{project: project} do + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Stable Slug", + content: "first body" + }) + + published_at = System.system_time(:second) - 5 + + {:ok, published} = + BDS.Posts.update_post(post.id, %{ + status: :published, + published_at: published_at, + content: nil, + file_path: "posts/2026/04/stable-slug.md" + }) + + assert published.status == :published + assert published.published_at == published_at + + assert {:error, changeset} = BDS.Posts.update_post(post.id, %{slug: "new-slug"}) + assert "cannot change slug after first publish" in errors_on(changeset).slug + + assert {:ok, reopened} = BDS.Posts.update_post(post.id, %{content: "revised draft"}) + assert reopened.status == :draft + assert reopened.slug == "stable-slug" + assert reopened.published_at == published_at + assert reopened.content == "revised draft" + assert reopened.updated_at >= published.updated_at + end + + defp errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/bds/projects_test.exs b/test/bds/projects_test.exs new file mode 100644 index 0000000..48b26d9 --- /dev/null +++ b/test/bds/projects_test.exs @@ -0,0 +1,40 @@ +defmodule BDS.ProjectsTest do + use ExUnit.Case, async: false + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + end + + test "create_project slugifies names, keeps new projects inactive, and deduplicates slugs" do + assert {:ok, first} = BDS.Projects.create_project(%{name: "Föö Bär Blog", data_path: "/tmp/blog"}) + + assert first.name == "Föö Bär Blog" + assert first.slug == "foo-bar-blog" + assert first.data_path == "/tmp/blog" + assert first.is_active == false + assert is_integer(first.created_at) + assert is_integer(first.updated_at) + + assert {:ok, second} = BDS.Projects.create_project(%{name: "Föö Bär Blog"}) + assert second.slug == "foo-bar-blog-2" + assert second.is_active == false + end + + test "set_active_project clears the previous active project and activates the target" do + assert {:ok, first} = BDS.Projects.create_project(%{name: "First"}) + assert {:ok, second} = BDS.Projects.create_project(%{name: "Second"}) + + assert {:ok, active_first} = BDS.Projects.set_active_project(first.id) + assert active_first.is_active == true + + assert {:ok, active_second} = BDS.Projects.set_active_project(second.id) + assert active_second.is_active == true + + refetched_first = BDS.Projects.get_project!(first.id) + refetched_second = BDS.Projects.get_project!(second.id) + + assert refetched_first.is_active == false + assert refetched_second.is_active == true + assert Enum.count(BDS.Projects.list_projects(), & &1.is_active) == 1 + end +end