Add comments and vote models; pub/sub voting on posts

This commit is contained in:
Logan Hunt 2022-04-15 13:00:42 -06:00
parent db7c2321cd
commit 3cf9f4a364
Signed by untrusted user who does not match committer: simponic
GPG Key ID: 52B3774857EB24B1
12 changed files with 141 additions and 10 deletions

View File

@ -14,6 +14,8 @@ defmodule Aggiedit.Accounts.User do
belongs_to :room, Room, on_replace: :update belongs_to :room, Room, on_replace: :update
has_many :posts, Aggiedit.Rooms.Post has_many :posts, Aggiedit.Rooms.Post
has_many :votes, Aggiedit.Post.Vote
has_many :comments, Aggiedit.Post.Comment
timestamps() timestamps()
end end

View File

@ -6,7 +6,7 @@ defmodule Aggiedit.Roles do
def guard?(user, action, object) def guard?(user, action, object)
def guard?(%User{role: :admin}, _, _), do: true def guard?(%User{role: :admin}, _, _), do: true
def guard?(%User{room_id: rid}, :index, %Room{id: rid}), do: true def guard?(%User{room_id: rid}, :index, %Room{id: rid}), do: true
def guard?(%User{room_id: rid}, :show, %Post{room_id: rid}), do: true def guard?(%User{room_id: rid}, action, %Post{room_id: rid}) when action in [:show, :vote], do: true
def guard?(%User{id: id, room_id: rid}, action, %Post{user_id: id, room_id: rid}) when action in [:delete, :edit], do: true def guard?(%User{id: id, room_id: rid}, action, %Post{user_id: id, room_id: rid}) when action in [:delete, :edit], do: true
def guard?(_, _, _), do: false def guard?(_, _, _), do: false

View File

@ -6,9 +6,11 @@ defmodule Aggiedit.Rooms do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Aggiedit.Repo alias Aggiedit.Repo
alias Aggiedit.Accounts alias Aggiedit.Accounts.User
alias Aggiedit.Rooms.Room alias Aggiedit.Rooms.Room
alias Aggiedit.Post.{Vote, Comment}
alias Phoenix.PubSub alias Phoenix.PubSub
def list_rooms do def list_rooms do
@ -91,6 +93,23 @@ defmodule Aggiedit.Rooms do
Post.changeset(post, attrs) Post.changeset(post, attrs)
end end
def vote_count(post) do
votes = post
|> Repo.preload(:votes)
|> Map.get(:votes)
|> Enum.map(fn vote -> if vote.is_up, do: 1, else: -1 end)
|> Enum.sum()
end
def vote_post(%Post{} = post, %User{} = user, direction) do
is_up = direction == "upvote"
vote = %Vote{is_up: is_up, user: user, post: post}
|> Repo.insert(on_conflict: [set: [is_up: is_up]], conflict_target: [:user_id, :post_id])
post = change_post(post, %{score: vote_count(post)})
|> Repo.update()
broadcast_post_over_room(post, :post_voted)
end
defp broadcast_post_over_room({:error, _reason}=error, _event), do: error defp broadcast_post_over_room({:error, _reason}=error, _event), do: error
defp broadcast_post_over_room({:ok, post}, event) do defp broadcast_post_over_room({:ok, post}, event) do
PubSub.broadcast(Aggiedit.PubSub, "room:#{post.room_id}:posts", {event, post}) PubSub.broadcast(Aggiedit.PubSub, "room:#{post.room_id}:posts", {event, post})

View File

@ -0,0 +1,20 @@
defmodule Aggiedit.Post.Comment do
use Ecto.Schema
import Ecto.Changeset
schema "post_comments" do
field :comment, :string
belongs_to :user, Aggiedit.Accounts.User
belongs_to :post, Aggiedit.Rooms.Post
timestamps()
end
@doc false
def changeset(comment, attrs) do
comment
|> cast(attrs, [:comment])
|> validate_required([:comment])
end
end

View File

@ -5,18 +5,22 @@ defmodule Aggiedit.Rooms.Post do
schema "posts" do schema "posts" do
field :body, :string field :body, :string
field :title, :string field :title, :string
field :score, :integer
belongs_to :room, Aggiedit.Rooms.Room belongs_to :room, Aggiedit.Rooms.Room
belongs_to :user, Aggiedit.Accounts.User belongs_to :user, Aggiedit.Accounts.User
belongs_to :upload, Aggiedit.Uploads.Upload belongs_to :upload, Aggiedit.Uploads.Upload
has_many :votes, Aggiedit.Post.Vote
has_many :comments, Aggiedit.Post.Comment
timestamps() timestamps()
end end
@doc false @doc false
def changeset(post, attrs) do def changeset(post, attrs) do
post post
|> cast(attrs, [:title, :body]) |> cast(attrs, [:title, :body, :score])
|> validate_required([:title, :body]) |> validate_required([:title, :body])
end end

View File

@ -0,0 +1,20 @@
defmodule Aggiedit.Post.Vote do
use Ecto.Schema
import Ecto.Changeset
schema "post_votes" do
field :is_up, :boolean
belongs_to :user, Aggiedit.Accounts.User
belongs_to :post, Aggiedit.Rooms.Post
timestamps()
end
@doc false
def changeset(vote, attrs) do
vote
|> cast(attrs, [:direction])
|> validate_required([:direction])
end
end

View File

@ -12,7 +12,14 @@ defmodule AggieditWeb.PostLive.Index do
case socket.assigns do case socket.assigns do
%{:room => room} -> %{:room => room} ->
if connected?(socket), do: Rooms.subscribe(socket.assigns.room) if connected?(socket), do: Rooms.subscribe(socket.assigns.room)
{:ok, assign(socket, %{:posts => room |> Repo.preload(posts: [:user, :upload]) |> Map.get(:posts)}), temporary_assigns: [posts: []]} posts = room
|> Repo.preload(posts: [:user, :upload])
|> Map.get(:posts)
votes = socket.assigns.current_user
|> Repo.preload(:votes)
|> Map.get(:votes)
|> Enum.reduce(%{}, fn v, a -> Map.put(a, v.post_id, v) end)
{:ok, assign(socket, %{:posts => posts, :votes => votes}), temporary_assigns: [posts: []]}
_ -> {:ok, socket} _ -> {:ok, socket}
end end
end end
@ -50,6 +57,16 @@ defmodule AggieditWeb.PostLive.Index do
|> assign(:post, nil) |> assign(:post, nil)
end end
def handle_event(vote, %{"id" => id}, socket) when vote in ["upvote", "downvote"] do
post = Rooms.get_post!(id)
if Roles.guard?(socket.assigns.current_user, :vote, post) do
Rooms.vote_post(post, socket.assigns.current_user, vote)
{:noreply, socket}
else
{:noreply, socket |> put_flash(:error, "You don't have permission to do that.") |> redirect(to: Routes.post_show_path(socket, :show, socket.assigns.room, post))}
end
end
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
post = Rooms.get_post!(id) post = Rooms.get_post!(id)
@ -62,7 +79,7 @@ defmodule AggieditWeb.PostLive.Index do
end end
@impl true @impl true
def handle_info({action, post}, socket) when action in [:post_created, :post_updated, :post_deleted] do def handle_info({action, post}, socket) when action in [:post_created, :post_updated, :post_deleted, :post_voted] do
{:noreply, update(socket, :posts, fn posts -> {:noreply, update(socket, :posts, fn posts ->
[posts | post] [posts | post]
end)} end)}

View File

@ -18,13 +18,29 @@
<div id="posts" phx-update="prepend"> <div id="posts" phx-update="prepend">
<%= for post <- @posts do %> <%= for post <- @posts do %>
<div id={"post-#{post.id}"} class="card d-flex flex-row align-items-center p-2 m-2 shadow"> <div id={"post-#{post.id}"} class="card d-flex flex-row align-items-center p-2 m-2 shadow">
<div class="d-flex flex-column m-2">
<%=
has_vote = Map.has_key?(@votes, post.id)
is_upvote = has_vote && @votes[post.id].is_up
""
%>
<div class="d-flex">
<span><%= link "", to: "#", phx_click: "upvote", phx_value_id: post.id, class: "bi bi-arrow-up-circle#{if has_vote && is_upvote, do: "-fill", else: ""}" %></span>
</div>
<div class="d-flex">
<%= post.score %>
</div>
<div class="d-flex">
<span><%= link "", to: "#", phx_click: "downvote", phx_value_id: post.id, class: "bi bi-arrow-down-circle#{if has_vote && !is_upvote, do: "-fill", else: ""}" %></span>
</div>
</div>
<div class="m-2 card-image d-flex justify-content-center" style="width: 100px">
<%= if !is_nil(post.upload) do %> <%= if !is_nil(post.upload) do %>
<%= live_redirect to: Routes.post_show_path(@socket, :show, @room, post) do %> <%= live_redirect to: Routes.post_show_path(@socket, :show, @room, post) do %>
<div class="card-image d-flex justify-content-center" style="width: 100px">
<img class="fluid-img thumbnail" src={Routes.static_path(@socket, "/uploads/#{post.upload.file}")} /> <img class="fluid-img thumbnail" src={Routes.static_path(@socket, "/uploads/#{post.upload.file}")} />
<% end %>
<% end %>
</div> </div>
<% end %>
<% end %>
<div class="card-body"> <div class="card-body">
<%= live_redirect to: Routes.post_show_path(@socket, :show, @room, post) do %> <%= live_redirect to: Routes.post_show_path(@socket, :show, @room, post) do %>
<h4 class="card-title"><%= post.title %></h4> <h4 class="card-title"><%= post.title %></h4>

View File

@ -11,6 +11,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.0/font/bootstrap-icons.css">
</head> </head>
<body class="bg-secondary min-vh-100"> <body class="bg-secondary min-vh-100">
<header> <header>

View File

@ -5,6 +5,7 @@ defmodule Aggiedit.Repo.Migrations.CreatePosts do
create table(:posts) do create table(:posts) do
add :title, :text add :title, :text
add :body, :text add :body, :text
add :score, :integer, default: 0
add :user_id, references(:users, on_delete: :nothing) add :user_id, references(:users, on_delete: :nothing)
add :upload_id, references(:uploads, on_delete: :nothing) add :upload_id, references(:uploads, on_delete: :nothing)
add :room_id, references(:rooms, on_delete: :nothing) add :room_id, references(:rooms, on_delete: :nothing)

View File

@ -0,0 +1,15 @@
defmodule Aggiedit.Repo.Migrations.CreatePostVotes do
use Ecto.Migration
def change do
create table(:post_votes) do
add :is_up, :boolean
add :user_id, references(:users, on_delete: :nothing)
add :post_id, references(:posts, on_delete: :delete_all)
timestamps()
end
create unique_index(:post_votes, [:user_id, :post_id])
end
end

View File

@ -0,0 +1,16 @@
defmodule Aggiedit.Repo.Migrations.CreatePostComments do
use Ecto.Migration
def change do
create table(:post_comments) do
add :comment, :text
add :user_id, references(:users, on_delete: :delete_all)
add :post_id, references(:posts, on_delete: :delete_all)
timestamps()
end
create index(:post_comments, [:user_id])
create index(:post_comments, [:post_id])
end
end