Skip to content

Commit

Permalink
Favorite/unfavorite articles
Browse files Browse the repository at this point in the history
Extend article schema to include `favorite` virtual field.
  • Loading branch information
slashdotdash committed Apr 30, 2018
1 parent 115fc14 commit d8529a8
Show file tree
Hide file tree
Showing 27 changed files with 660 additions and 111 deletions.
95 changes: 86 additions & 9 deletions lib/conduit/blog/aggregates/article.ex
@@ -1,17 +1,29 @@
defmodule Conduit.Blog.Aggregates.Article do
defstruct [
:uuid,
:slug,
:title,
:description,
:body,
:tag_list,
:author_uuid,
uuid: nil,
slug: nil,
title: nil,
description: nil,
body: nil,
tag_list: nil,
author_uuid: nil,
favorited_by_authors: MapSet.new(),
favorite_count: 0,
]

alias Conduit.Blog.Aggregates.Article
alias Conduit.Blog.Commands.PublishArticle
alias Conduit.Blog.Events.ArticlePublished

alias Conduit.Blog.Commands.{
FavoriteArticle,
PublishArticle,
UnfavoriteArticle,
}

alias Conduit.Blog.Events.{
ArticleFavorited,
ArticlePublished,
ArticleUnfavorited,
}

@doc """
Publish an article
Expand All @@ -28,6 +40,44 @@ defmodule Conduit.Blog.Aggregates.Article do
}
end

@doc """
Favorite the article for an author
"""
def execute(%Article{uuid: nil}, %FavoriteArticle{}), do: {:error, :article_not_found}
def execute(
%Article{uuid: uuid, favorite_count: favorite_count} = article,
%FavoriteArticle{favorited_by_author_uuid: author_id})
do
case is_favorited?(article, author_id) do
true -> nil
false ->
%ArticleFavorited{
article_uuid: uuid,
favorited_by_author_uuid: author_id,
favorite_count: favorite_count + 1,
}
end
end

@doc """
Unfavorite the article for the user
"""
def execute(%Article{uuid: nil}, %UnfavoriteArticle{}), do: {:error, :article_not_found}
def execute(
%Article{uuid: uuid, favorite_count: favorite_count} = article,
%UnfavoriteArticle{unfavorited_by_author_uuid: author_id})
do
case is_favorited?(article, author_id) do
true ->
%ArticleUnfavorited{
article_uuid: uuid,
unfavorited_by_author_uuid: author_id,
favorite_count: favorite_count - 1,
}
false -> nil
end
end

# state mutators

def apply(%Article{} = article, %ArticlePublished{} = published) do
Expand All @@ -41,4 +91,31 @@ defmodule Conduit.Blog.Aggregates.Article do
author_uuid: published.author_uuid,
}
end

def apply(
%Article{favorited_by_authors: favorited_by} = article,
%ArticleFavorited{favorited_by_author_uuid: author_id, favorite_count: favorite_count})
do
%Article{article |
favorited_by_authors: MapSet.put(favorited_by, author_id),
favorite_count: favorite_count,
}
end

def apply(
%Article{favorited_by_authors: favorited_by} = article,
%ArticleUnfavorited{unfavorited_by_author_uuid: author_id, favorite_count: favorite_count})
do
%Article{article |
favorited_by_authors: MapSet.delete(favorited_by, author_id),
favorite_count: favorite_count,
}
end

# private helpers

# Is the article a favorite of the user?
defp is_favorited?(%Article{favorited_by_authors: favorited_by}, user_uuid) do
MapSet.member?(favorited_by, user_uuid)
end
end
63 changes: 52 additions & 11 deletions lib/conduit/blog/blog.ex
Expand Up @@ -3,17 +3,23 @@ defmodule Conduit.Blog do
The boundary for the Blog system.
"""

alias Conduit.Blog.Commands.{CreateAuthor,PublishArticle}
alias Conduit.Accounts.Projections.User
alias Conduit.Blog.Commands.{CreateAuthor,FavoriteArticle,PublishArticle,UnfavoriteArticle}
alias Conduit.Blog.Projections.{Article,Author}
alias Conduit.Blog.Queries.{ArticleBySlug,ListArticles}
alias Conduit.{Repo,Router}

@doc """
Get the author for a given uuid.
Get the author for a given uuid, or raise an `Ecto.NoResultsError` if not found.
"""
def get_author!(uuid) do
Repo.get!(Author, uuid)
end
def get_author!(uuid), do: Repo.get!(Author, uuid)

@doc """
Get the author for a given uuid, or nil if the user is nil.
"""
def get_author(nil), do: nil
def get_author(%User{uuid: user_uuid}), do: get_author(user_uuid)
def get_author(uuid) when is_bitstring(uuid), do: Repo.get(Author, uuid)

@doc """
Get an article by its URL slug, or return `nil` if not found
Expand All @@ -22,7 +28,7 @@ defmodule Conduit.Blog do
do: article_by_slug_query(slug) |> Repo.one()

@doc """
Get an article by its URL slug, or raise an `Ecto.NoResultsError` if not found
Get an article by its URL slug, or raise an `Ecto.NoResultsError` if not found.
"""
def article_by_slug!(slug),
do: article_by_slug_query(slug) |> Repo.one!()
Expand All @@ -32,9 +38,10 @@ defmodule Conduit.Blog do
Provide tag, author or favorited query parameter to filter results.
"""
@spec list_articles(params :: map()) :: {articles :: list(Article.t), article_count :: non_neg_integer()}
def list_articles(params \\ %{}) do
ListArticles.paginate(params, Repo)
@spec list_articles(params :: map(), author :: Author.t) :: {articles :: list(Article.t), article_count :: non_neg_integer()}
def list_articles(params \\ %{}, author \\ nil)
def list_articles(params, author) do
ListArticles.paginate(params, author, Repo)
end

@doc """
Expand Down Expand Up @@ -68,8 +75,42 @@ defmodule Conduit.Blog do
|> PublishArticle.assign_author(author)
|> PublishArticle.generate_url_slug()

with :ok <- Router.dispatch(publish_article, consistency: :strong) do
get(Article, uuid)
with :ok <- Router.dispatch(publish_article, consistency: :strong) do
get(Article, uuid)
else
reply -> reply
end
end

@doc """
Favorite the article for an author
"""
def favorite_article(%Article{uuid: article_uuid}, %Author{uuid: author_uuid}) do
favorite_article = %FavoriteArticle{
article_uuid: article_uuid,
favorited_by_author_uuid: author_uuid,
}

with :ok <- Router.dispatch(favorite_article, consistency: :strong),
{:ok, article} <- get(Article, article_uuid) do
{:ok, %Article{article | favorited: true}}
else
reply -> reply
end
end

@doc """
Unfavorite the article for an author
"""
def unfavorite_article(%Article{uuid: article_uuid}, %Author{uuid: author_uuid}) do
unfavorite_article = %UnfavoriteArticle{
article_uuid: article_uuid,
unfavorited_by_author_uuid: author_uuid,
}

with :ok <- Router.dispatch(unfavorite_article, consistency: :strong),
{:ok, article} <- get(Article, article_uuid) do
{:ok, %Article{article | favorited: false}}
else
reply -> reply
end
Expand Down
12 changes: 12 additions & 0 deletions lib/conduit/blog/commands/favorite_article.ex
@@ -0,0 +1,12 @@
defmodule Conduit.Blog.Commands.FavoriteArticle do
defstruct [
article_uuid: "",
favorited_by_author_uuid: "",
]

use ExConstructor
use Vex.Struct

validates :article_uuid, uuid: true
validates :favorited_by_author_uuid, uuid: true
end
12 changes: 12 additions & 0 deletions lib/conduit/blog/commands/unfavorite_article.ex
@@ -0,0 +1,12 @@
defmodule Conduit.Blog.Commands.UnfavoriteArticle do
defstruct [
article_uuid: "",
unfavorited_by_author_uuid: "",
]

use ExConstructor
use Vex.Struct

validates :article_uuid, uuid: true
validates :unfavorited_by_author_uuid, uuid: true
end
8 changes: 8 additions & 0 deletions lib/conduit/blog/events/article_favorited.ex
@@ -0,0 +1,8 @@
defmodule Conduit.Blog.Events.ArticleFavorited do
@derive [Poison.Encoder]
defstruct [
:article_uuid,
:favorited_by_author_uuid,
:favorite_count,
]
end
8 changes: 8 additions & 0 deletions lib/conduit/blog/events/article_unfavorited.ex
@@ -0,0 +1,8 @@
defmodule Conduit.Blog.Events.ArticleUnfavorited do
@derive [Poison.Encoder]
defstruct [
:article_uuid,
:unfavorited_by_author_uuid,
:favorite_count,
]
end
12 changes: 12 additions & 0 deletions lib/conduit/blog/favorited_article.ex
@@ -0,0 +1,12 @@
defmodule Conduit.Blog.Projections.FavoritedArticle do
use Ecto.Schema

@primary_key false

schema "blog_favorited_articles" do
field :article_uuid, :binary_id, primary_key: true
field :favorited_by_author_uuid, :binary_id, primary_key: true

timestamps()
end
end
1 change: 1 addition & 0 deletions lib/conduit/blog/projections/article.ex
Expand Up @@ -9,6 +9,7 @@ defmodule Conduit.Blog.Projections.Article do
field :description, :string
field :body, :string
field :tag_list, {:array, :string}
field :favorited, :boolean, virtual: true, default: false
field :favorite_count, :integer, default: 0
field :published_at, :naive_datetime
field :author_uuid, :binary_id
Expand Down
39 changes: 37 additions & 2 deletions lib/conduit/blog/projectors/article.ex
Expand Up @@ -3,8 +3,13 @@ defmodule Conduit.Blog.Projectors.Article do
name: "Blog.Projectors.Article",
consistency: :strong

alias Conduit.Blog.Events.{ArticlePublished,AuthorCreated}
alias Conduit.Blog.Projections.{Article,Author}
alias Conduit.Blog.Projections.{Article,Author,FavoritedArticle}
alias Conduit.Blog.Events.{
ArticleFavorited,
ArticlePublished,
ArticleUnfavorited,
AuthorCreated,
}
alias Conduit.Repo

project %AuthorCreated{} = author do
Expand Down Expand Up @@ -40,10 +45,40 @@ defmodule Conduit.Blog.Projectors.Article do
end)
end

@doc """
Update favorite count when an article is favorited
"""
project %ArticleFavorited{article_uuid: article_uuid, favorited_by_author_uuid: favorited_by_author_uuid, favorite_count: favorite_count} do
multi
|> Ecto.Multi.insert(:favorited_article, %FavoritedArticle{article_uuid: article_uuid, favorited_by_author_uuid: favorited_by_author_uuid})
|> Ecto.Multi.update_all(:article, article_query(article_uuid), set: [
favorite_count: favorite_count,
])
end

@doc """
Update favorite count when an article is unfavorited
"""
project %ArticleUnfavorited{article_uuid: article_uuid, unfavorited_by_author_uuid: unfavorited_by_author_uuid, favorite_count: favorite_count} do
multi
|> Ecto.Multi.delete_all(:favorited_article, favorited_article_query(article_uuid, unfavorited_by_author_uuid))
|> Ecto.Multi.update_all(:article, article_query(article_uuid), set: [
favorite_count: favorite_count,
])
end

defp get_author(uuid) do
case Repo.get(Author, uuid) do
nil -> {:error, :author_not_found}
author -> {:ok, author}
end
end

defp article_query(article_uuid) do
from(a in Article, where: a.uuid == ^article_uuid)
end

defp favorited_article_query(article_uuid, author_uuid) do
from(f in FavoritedArticle, where: f.article_uuid == ^article_uuid and f.favorited_by_author_uuid == ^author_uuid)
end
end
18 changes: 13 additions & 5 deletions lib/conduit/blog/queries/list_articles.ex
@@ -1,7 +1,7 @@
defmodule Conduit.Blog.Queries.ListArticles do
import Ecto.Query

alias Conduit.Blog.Projections.Article
alias Conduit.Blog.Projections.{Article,Author,FavoritedArticle}

defmodule Options do
defstruct [
Expand All @@ -14,11 +14,11 @@ defmodule Conduit.Blog.Queries.ListArticles do
use ExConstructor
end

def paginate(params, repo) do
def paginate(params, author, repo) do
options = Options.new(params)
query = options |> query()
query = query(options)

articles = query |> entries(options) |> repo.all()
articles = query |> entries(options, author) |> repo.all()
total_count = query |> count() |> repo.aggregate(:count, :uuid)

{articles, total_count}
Expand All @@ -30,8 +30,9 @@ defmodule Conduit.Blog.Queries.ListArticles do
|> filter_by_tag(options)
end

defp entries(query, %Options{limit: limit, offset: offset}) do
defp entries(query, %Options{limit: limit, offset: offset}, author) do
query
|> include_favorited_by_author(author)
|> order_by([a], desc: a.published_at)
|> limit(^limit)
|> offset(^offset)
Expand All @@ -51,4 +52,11 @@ defmodule Conduit.Blog.Queries.ListArticles do
from a in query,
where: fragment("? @> ?", a.tag_list, [^tag])
end

defp include_favorited_by_author(query, nil), do: query
defp include_favorited_by_author(query, %Author{uuid: author_uuid}) do
from a in query,
left_join: f in FavoritedArticle, on: [article_uuid: a.uuid, favorited_by_author_uuid: ^author_uuid],
select: %{a | favorited: not is_nil(f.article_uuid)}
end
end

0 comments on commit d8529a8

Please sign in to comment.