Skip to content

Commit 695dd70

Browse files
committed
feat: GraphQL subscriptions
1 parent 6b74ecd commit 695dd70

File tree

22 files changed

+611
-7
lines changed

22 files changed

+611
-7
lines changed

apps/cf/lib/comments/comments.ex

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,25 @@ defmodule CF.Comments do
3737
source fetcher should be moved to a job.
3838
"""
3939
def add_comment(user, video_id, params, source_url \\ nil, source_fetch_callback \\ nil) do
40-
# TODO [Security] What if reply_to_id refer to a comment that is on a different statement ?
4140
UserPermissions.check!(user, :create, :comment)
4241
source_url = source_url && Source.prepare_url(source_url)
4342

43+
# Handle the case where reply_to_id refer to a comment that is on a different statement
44+
if Map.get(params, :reply_to_id) do
45+
reply_to = Repo.get!(Comment, Map.get(params, :reply_to_id))
46+
47+
cond do
48+
is_nil(reply_to) ->
49+
raise "Reply to comment not found"
50+
51+
reply_to.statement_id != params.statement_id ->
52+
raise "Reply to comment on a different statement"
53+
54+
true ->
55+
true
56+
end
57+
end
58+
4459
# Load source from DB or create a changeset to make a new one
4560
source =
4661
source_url &&

apps/cf/lib/statements/statements.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ defmodule CF.Statements do
1818
"""
1919
def update!(user_id, statement = %Statement{is_removed: false}, changes) do
2020
UserPermissions.check!(user_id, :update, :statement)
21-
changeset = Statement.changeset(statement, changes)
21+
changeset = Statement.changeset_update(statement, changes)
2222

2323
if changeset.changes == %{} do
2424
Result.ok(statement)

apps/cf_graphql/lib/application.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ defmodule CF.Graphql.Application do
99
# Start the PubSub system
1010
{Phoenix.PubSub, name: CF.Graphql.PubSub},
1111
# Start the endpoint when the application starts
12-
{CF.GraphQLWeb.Endpoint, []}
12+
{CF.GraphQLWeb.Endpoint, []},
13+
{Absinthe.Subscription, CF.GraphQLWeb.Endpoint}
1314
]
1415

1516
# See https://hexdocs.pm/elixir/Supervisor.html

apps/cf_graphql/lib/endpoint.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
defmodule CF.GraphQLWeb.Endpoint do
22
use Phoenix.Endpoint, otp_app: :cf_graphql
3+
use Absinthe.Phoenix.Endpoint
4+
5+
socket("/socket", CF.GraphQLWeb.UserSocket, websocket: true, longpoll: false)
36

47
plug(Plug.RequestId)
58
plug(Plug.Logger)

apps/cf_graphql/lib/resolvers/comments.ex

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ defmodule CF.Graphql.Resolvers.Comments do
33
import Ecto.Query
44
alias DB.Repo
55
alias DB.Schema.Vote
6+
alias DB.Schema.Comment
7+
alias DB.Schema.Statement
8+
alias CF.Comments
9+
alias CF.Graphql.Subscriptions
610

711
def score(comment, _args, _info) do
812
batch({__MODULE__, :comments_scores}, comment.id, fn results ->
@@ -18,4 +22,75 @@ defmodule CF.Graphql.Resolvers.Comments do
1822
|> Repo.all()
1923
|> Enum.into(%{})
2024
end
25+
26+
# Mutations
27+
28+
def create(_root, args = %{statement_id: statement_id}, %{context: %{user: user}}) do
29+
# Get statement to find video_id
30+
statement = Repo.get!(Statement, statement_id)
31+
video_id = statement.video_id
32+
reply_to_id = Map.get(args, :reply_to_id)
33+
34+
params = %{
35+
"statement_id" => statement_id,
36+
"text" => Map.get(args, :text),
37+
"reply_to_id" => reply_to_id,
38+
"approve" => Map.get(args, :approve)
39+
}
40+
41+
source_url = Map.get(args, :source)
42+
43+
# Comments.add_comment returns the comment directly or {:error, reason}
44+
case Comments.add_comment(user, video_id, params, source_url) do
45+
{:error, reason} ->
46+
{:error, reason}
47+
48+
comment ->
49+
# Preload associations for GraphQL response
50+
comment = Repo.preload(comment, [:source, :user, :statement])
51+
52+
Subscriptions.publish_comment_added(comment, video_id)
53+
{:ok, comment}
54+
end
55+
end
56+
57+
def delete(_root, %{id: id}, %{context: %{user: user}}) do
58+
comment = Repo.get!(Comment, id) |> Repo.preload(:statement)
59+
video_id = comment.statement.video_id
60+
61+
case Comments.delete_comment(user, video_id, comment) do
62+
nil ->
63+
{:ok, %{id: id, statement_id: comment.statement_id, reply_to_id: comment.reply_to_id}}
64+
65+
_ ->
66+
Subscriptions.publish_comment_removed(comment, video_id)
67+
{:ok, %{id: id, statement_id: comment.statement_id, reply_to_id: comment.reply_to_id}}
68+
end
69+
end
70+
71+
def vote(_root, %{comment_id: comment_id, value: value}, %{context: %{user: user}}) do
72+
# Get comment and preload statement to access video_id
73+
comment = Repo.get!(Comment, comment_id) |> Repo.preload(:statement)
74+
video_id = comment.statement.video_id
75+
76+
case Comments.vote!(user, video_id, comment_id, value) do
77+
{:ok, comment, vote, prev_value} ->
78+
# Calculate score diff (same logic as comments_channel.ex)
79+
diff = value_diff(prev_value, vote.value)
80+
81+
# Publish score diff via subscription
82+
Subscriptions.publish_comment_score_diff(comment, diff, video_id)
83+
84+
# Preload associations for GraphQL response
85+
comment = Repo.preload(comment, [:source, :user, :statement])
86+
{:ok, comment}
87+
88+
{:error, reason} ->
89+
{:error, reason}
90+
end
91+
end
92+
93+
# Helper function to calculate vote value diff (matches comments_channel.ex logic)
94+
defp value_diff(0, new_value), do: new_value
95+
defp value_diff(prev_value, new_value), do: new_value - prev_value
2196
end

apps/cf_graphql/lib/resolvers/statements.ex

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@ defmodule CF.Graphql.Resolvers.Statements do
55

66
alias Kaur.Result
77

8+
alias Ecto.Multi
89
alias DB.Repo
910
alias DB.Schema.Statement
11+
alias CF.Accounts.UserPermissions
12+
alias CF.Actions.ActionCreator
13+
alias CF.Graphql.Subscriptions
14+
alias CF.Algolia.StatementsIndex
15+
alias CF.Statements
16+
17+
import CF.Actions.ActionCreator, only: [action_remove: 2]
1018

1119
# Queries
1220

@@ -16,4 +24,67 @@ defmodule CF.Graphql.Resolvers.Statements do
1624
|> Repo.paginate(page: offset, page_size: limit)
1725
|> Result.ok()
1826
end
27+
28+
# Mutations
29+
30+
def create(_root, args = %{video_id: video_id, text: _text, time: _time}, %{
31+
context: %{user: user}
32+
}) do
33+
user_id = user.id
34+
UserPermissions.check!(user_id, :create, :statement)
35+
36+
# Absinthe automatically converts GraphQL camelCase to snake_case
37+
changeset = Statement.changeset(%Statement{video_id: video_id}, args)
38+
39+
Multi.new()
40+
|> Multi.insert(:statement, changeset)
41+
|> Multi.run(:action_create, fn _repo, %{statement: statement} ->
42+
Repo.insert(ActionCreator.action_create(user_id, statement))
43+
end)
44+
|> Repo.transaction()
45+
|> case do
46+
{:ok, %{statement: statement}} ->
47+
Subscriptions.publish_statement_added(statement)
48+
StatementsIndex.save_object(statement)
49+
{:ok, statement}
50+
51+
{:error, _operation, reason, _changes} ->
52+
{:error, reason}
53+
end
54+
end
55+
56+
def update(_root, args = %{id: id}, %{context: %{user: user}}) when not is_nil(id) do
57+
user_id = user.id
58+
statement = Repo.get_by!(Statement, id: id, is_removed: false)
59+
60+
case Statements.update!(user_id, statement, args) do
61+
{:ok, updated_statement} ->
62+
Subscriptions.publish_statement_updated(updated_statement)
63+
StatementsIndex.save_object(updated_statement)
64+
{:ok, updated_statement}
65+
66+
{:error, reason} ->
67+
{:error, reason}
68+
end
69+
end
70+
71+
def delete(_root, %{id: id}, %{context: %{user: user}}) do
72+
user_id = user.id
73+
UserPermissions.check!(user_id, :remove, :statement)
74+
statement = Repo.get_by!(Statement, id: id, is_removed: false)
75+
76+
Multi.new()
77+
|> Multi.update(:statement, Statement.changeset_remove(statement))
78+
|> Multi.insert(:action_remove, action_remove(user_id, statement))
79+
|> Repo.transaction()
80+
|> case do
81+
{:ok, _} ->
82+
Subscriptions.publish_statement_removed(id, statement.video_id)
83+
StatementsIndex.delete_object(statement)
84+
{:ok, %{id: id}}
85+
86+
{:error, _operation, reason, _changes} ->
87+
{:error, reason}
88+
end
89+
end
1990
end

0 commit comments

Comments
 (0)