diff --git a/apps/cf/lib/comments/comments.ex b/apps/cf/lib/comments/comments.ex index 15cfd110..9aac0b83 100644 --- a/apps/cf/lib/comments/comments.ex +++ b/apps/cf/lib/comments/comments.ex @@ -37,10 +37,25 @@ defmodule CF.Comments do source fetcher should be moved to a job. """ def add_comment(user, video_id, params, source_url \\ nil, source_fetch_callback \\ nil) do - # TODO [Security] What if reply_to_id refer to a comment that is on a different statement ? UserPermissions.check!(user, :create, :comment) source_url = source_url && Source.prepare_url(source_url) + # Handle the case where reply_to_id refer to a comment that is on a different statement + if Map.get(params, :reply_to_id) do + reply_to = Repo.get!(Comment, Map.get(params, :reply_to_id)) + + cond do + is_nil(reply_to) -> + raise "Reply to comment not found" + + reply_to.statement_id != params.statement_id -> + raise "Reply to comment on a different statement" + + true -> + true + end + end + # Load source from DB or create a changeset to make a new one source = source_url && @@ -78,8 +93,10 @@ defmodule CF.Comments do full_comment = comment # If new source, fetch metadata - if source && is_nil(Map.get(source, :id)), - do: fetch_source_metadata_and_update_comment(comment, source_fetch_callback) + if source && is_nil(Map.get(source, :id)) do + callback = source_fetch_callback || fn _comment -> :ok end + fetch_source_metadata_and_update_comment(comment, callback) + end # Return comment full_comment @@ -251,11 +268,14 @@ defmodule CF.Comments do defp reverse_vote_type(:vote_down), do: :revert_vote_down defp reverse_vote_type(:self_vote), do: :revert_self_vote - defp fetch_source_metadata_and_update_comment(%Comment{source: nil}, _), do: nil + defp fetch_source_metadata_and_update_comment(%Comment{source: nil}, _callback), do: nil defp fetch_source_metadata_and_update_comment(comment = %Comment{source: base_source}, callback) do Sources.update_source_metadata(base_source, fn updated_source -> - callback.(Map.merge(comment, %{source: updated_source, source_id: updated_source.id})) + updated_comment = + Map.merge(comment, %{source: updated_source, source_id: updated_source.id}) + + callback.(updated_comment) end) end end diff --git a/apps/cf/lib/sources/fetcher.ex b/apps/cf/lib/sources/fetcher.ex index 64e471c1..89af13d5 100644 --- a/apps/cf/lib/sources/fetcher.ex +++ b/apps/cf/lib/sources/fetcher.ex @@ -77,7 +77,7 @@ defmodule CF.Sources.Fetcher do defp do_fetch_source_metadata(url, mime_types) when mime_types in @fetchable_mime_types do case HTTPoison.get( url, - [{"User-Agent", "CaptainFact/2.0"}], + [{"User-Agent", "CaptainFact/#{CF.Application.version()}"}], follow_redirect: true, max_redirect: 5, hackney: [pool: pool_name()] diff --git a/apps/cf/lib/statements/statements.ex b/apps/cf/lib/statements/statements.ex index ca555092..f29b4b4e 100644 --- a/apps/cf/lib/statements/statements.ex +++ b/apps/cf/lib/statements/statements.ex @@ -18,7 +18,7 @@ defmodule CF.Statements do """ def update!(user_id, statement = %Statement{is_removed: false}, changes) do UserPermissions.check!(user_id, :update, :statement) - changeset = Statement.changeset(statement, changes) + changeset = Statement.changeset_update(statement, changes) if changeset.changes == %{} do Result.ok(statement) diff --git a/apps/cf/lib/video_debate/history.ex b/apps/cf/lib/video_debate/history.ex index ffe75643..1e39b256 100644 --- a/apps/cf/lib/video_debate/history.ex +++ b/apps/cf/lib/video_debate/history.ex @@ -11,6 +11,7 @@ defmodule CF.VideoDebate.History do |> preload(:user) |> where([a], a.video_id == ^video_id) |> where([a], a.entity in ^@allowed_entities) + |> order_by([a], desc: a.inserted_at) |> Repo.all() end @@ -19,6 +20,7 @@ defmodule CF.VideoDebate.History do |> preload(:user) |> where([a], a.entity == ^:statement) |> where([a], a.statement_id == ^statement_id) + |> order_by([a], desc: a.inserted_at) |> Repo.all() end end diff --git a/apps/cf_graphql/lib/application.ex b/apps/cf_graphql/lib/application.ex index 9da7939e..916de245 100644 --- a/apps/cf_graphql/lib/application.ex +++ b/apps/cf_graphql/lib/application.ex @@ -9,7 +9,8 @@ defmodule CF.Graphql.Application do # Start the PubSub system {Phoenix.PubSub, name: CF.Graphql.PubSub}, # Start the endpoint when the application starts - {CF.GraphQLWeb.Endpoint, []} + {CF.GraphQLWeb.Endpoint, []}, + {Absinthe.Subscription, CF.GraphQLWeb.Endpoint} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/apps/cf_graphql/lib/custom_absinthe_plug.ex b/apps/cf_graphql/lib/custom_absinthe_plug.ex new file mode 100644 index 00000000..bd6b04da --- /dev/null +++ b/apps/cf_graphql/lib/custom_absinthe_plug.ex @@ -0,0 +1,48 @@ +defmodule CF.Graphql.CustomAbsinthePlug do + @moduledoc """ + Custom Absinthe plug that catches all exceptions and converts them + to proper GraphQL errors instead of letting Phoenix return HTML error pages. + """ + + import Plug.Conn + alias CF.Accounts.UserPermissions.PermissionsError + + def init(opts) do + Absinthe.Plug.init(opts) + end + + @spec call(Plug.Conn.t(), any()) :: Plug.Conn.t() + def call(conn, opts) do + try do + Absinthe.Plug.call(conn, opts) + rescue + e in PermissionsError -> + # Convert the PermissionsError to a GraphQL error response + conn + |> put_resp_content_type("application/json") + |> send_resp(200, build_graphql_error_response(e.message, "FORBIDDEN")) + + exception -> + # Convert any other exception to a GraphQL error response + error_message = Exception.message(exception) + + conn + |> put_resp_content_type("application/json") + |> send_resp(200, build_graphql_error_response(error_message, "INTERNAL_ERROR")) + end + end + + defp build_graphql_error_response(message, code) do + Jason.encode!(%{ + "errors" => [ + %{ + "message" => message, + "extensions" => %{ + "code" => code + } + } + ], + "data" => nil + }) + end +end diff --git a/apps/cf_graphql/lib/endpoint.ex b/apps/cf_graphql/lib/endpoint.ex index 84883ac6..a31639fb 100644 --- a/apps/cf_graphql/lib/endpoint.ex +++ b/apps/cf_graphql/lib/endpoint.ex @@ -1,5 +1,8 @@ defmodule CF.GraphQLWeb.Endpoint do use Phoenix.Endpoint, otp_app: :cf_graphql + use Absinthe.Phoenix.Endpoint + + socket("/socket", CF.GraphQLWeb.UserSocket, websocket: true, longpoll: false) plug(Plug.RequestId) plug(Plug.Logger) diff --git a/apps/cf_graphql/lib/resolvers/comments.ex b/apps/cf_graphql/lib/resolvers/comments.ex index e64ca260..47debc22 100644 --- a/apps/cf_graphql/lib/resolvers/comments.ex +++ b/apps/cf_graphql/lib/resolvers/comments.ex @@ -3,6 +3,11 @@ defmodule CF.Graphql.Resolvers.Comments do import Ecto.Query alias DB.Repo alias DB.Schema.Vote + alias DB.Schema.Comment + alias DB.Schema.Statement + alias CF.Comments + alias CF.Graphql.Subscriptions + alias CF.Moderation.Flagger def score(comment, _args, _info) do batch({__MODULE__, :comments_scores}, comment.id, fn results -> @@ -18,4 +23,92 @@ defmodule CF.Graphql.Resolvers.Comments do |> Repo.all() |> Enum.into(%{}) end + + # Mutations + + def create(_root, args = %{statement_id: statement_id}, %{context: %{user: user}}) do + # Get statement to find video_id + statement = Repo.get!(Statement, statement_id) + video_id = statement.video_id + reply_to_id = Map.get(args, :reply_to_id) + + params = %{ + "statement_id" => statement_id, + "text" => Map.get(args, :text), + "reply_to_id" => reply_to_id, + "approve" => Map.get(args, :approve) + } + + source_url = Map.get(args, :source) + + # Callback to broadcast comment_updated when source metadata is fetched + update_callback = fn updated_comment -> + updated_comment = Repo.preload(updated_comment, [:source, :user, :statement]) + Subscriptions.publish_comment_updated(updated_comment, video_id) + end + + try do + case Comments.add_comment(user, video_id, params, source_url, update_callback) do + {:error, reason} -> + {:error, reason} + + comment -> + comment = Repo.preload(comment, [:source, :user, :statement]) + Subscriptions.publish_comment_added(comment, video_id) + {:ok, comment} + end + rescue + exception -> + {:error, exception} + end + end + + def delete(_root, %{id: id}, %{context: %{user: user}}) do + comment = Repo.get!(Comment, id) |> Repo.preload(:statement) + video_id = comment.statement.video_id + + case Comments.delete_comment(user, video_id, comment) do + nil -> + {:ok, %{id: id, statement_id: comment.statement_id, reply_to_id: comment.reply_to_id}} + + _ -> + Subscriptions.publish_comment_removed(comment, video_id) + {:ok, %{id: id, statement_id: comment.statement_id, reply_to_id: comment.reply_to_id}} + end + end + + def vote(_root, %{comment_id: comment_id, value: value}, %{context: %{user: user}}) do + # Get comment and preload statement to access video_id + comment = Repo.get!(Comment, comment_id) |> Repo.preload(:statement) + video_id = comment.statement.video_id + + case Comments.vote!(user, video_id, comment_id, value) do + {:ok, comment, vote, prev_value} -> + # Calculate score diff (same logic as comments_channel.ex) + diff = value_diff(prev_value, vote.value) + + # Publish score diff via subscription + Subscriptions.publish_comment_score_diff(comment, diff, video_id) + + # Preload associations for GraphQL response + comment = Repo.preload(comment, [:source, :user, :statement]) + {:ok, comment} + + {:error, reason} -> + {:error, reason} + end + end + + def flag(_root, %{comment_id: comment_id, reason: reason}, %{context: %{user: user}}) do + # Get comment and preload statement to access video_id + comment = Repo.get!(Comment, comment_id) |> Repo.preload(:statement) + video_id = comment.statement.video_id + + Flagger.flag!(user.id, video_id, comment_id, reason) + {:ok, %{id: comment_id}} + end + + # Helper function to calculate vote value diff (matches comments_channel.ex logic) + defp value_diff(0, new_value), do: new_value + defp value_diff(prev_value, new_value), do: new_value - prev_value end diff --git a/apps/cf_graphql/lib/resolvers/history.ex b/apps/cf_graphql/lib/resolvers/history.ex new file mode 100644 index 00000000..9b741c83 --- /dev/null +++ b/apps/cf_graphql/lib/resolvers/history.ex @@ -0,0 +1,17 @@ +defmodule CF.Graphql.Resolvers.History do + @moduledoc """ + Resolvers for history-related queries + """ + + alias CF.VideoDebate.History + + def video_history_actions(_root, %{video_id: video_id}, _info) do + {:ok, History.video_history(video_id)} + end + + def statement_history_actions(_root, %{statement_id: statement_id}, _info) do + {:ok, History.statement_history(statement_id)} + end +end + + diff --git a/apps/cf_graphql/lib/resolvers/speakers.ex b/apps/cf_graphql/lib/resolvers/speakers.ex index f5a32eec..9912122b 100644 --- a/apps/cf_graphql/lib/resolvers/speakers.ex +++ b/apps/cf_graphql/lib/resolvers/speakers.ex @@ -1,5 +1,229 @@ defmodule CF.Graphql.Resolvers.Speakers do + @moduledoc """ + Resolver for speaker-related GraphQL operations + """ + + import Ecto.Query + alias Kaur.Result + alias Ecto.Multi + alias DB.Repo + alias DB.Schema.Speaker + alias DB.Schema.VideoSpeaker + alias CF.Accounts.UserPermissions + alias CF.Actions.ActionCreator + alias CF.Graphql.Subscriptions + alias CF.Algolia.SpeakersIndex + + import CF.Actions.ActionCreator, + only: [action_add: 3, action_create: 2, action_remove: 3, action_restore: 3] + def picture(speaker, _, _) do {:ok, DB.Type.SpeakerPicture.full_url(speaker, :thumb)} end + + @doc """ + Get a single speaker by ID or slug + """ + def get(_root, %{id: id}, _info) do + case Repo.get(Speaker, id) do + nil -> {:error, "Speaker #{id} doesn't exist"} + speaker -> {:ok, speaker} + end + end + + def get(_root, %{slug: slug}, _info) do + slug = Slugger.slugify(slug) + + case Repo.get_by(Speaker, slug: slug) do + nil -> {:error, "Speaker with slug #{slug} doesn't exist"} + speaker -> {:ok, speaker} + end + end + + @doc """ + Search for speakers by name + """ + def search_speakers(_root, %{query: query}, _info) when byte_size(query) < 3 do + {:ok, []} + end + + def search_speakers(_root, %{query: query, limit: limit}, _info) do + query_pattern = "%#{query}%" + + speakers_query = + from( + s in Speaker, + where: fragment("unaccent(?) ILIKE unaccent(?)", s.full_name, ^query_pattern), + group_by: s.id, + select: %{id: s.id, full_name: s.full_name, slug: s.slug, picture: s.picture}, + limit: ^limit + ) + + {:ok, Repo.all(speakers_query)} + end + + def search_speakers(_root, %{query: query}, _info) do + search_speakers(_root, %{query: query, limit: 5}, _info) + end + + @doc """ + Add an existing speaker to a video + """ + def add_speaker_to_video(_root, %{video_id: video_id, speaker_id: speaker_id}, %{ + context: %{user: user} + }) do + user_id = user.id + video_id = String.to_integer(video_id) + speaker_id = String.to_integer(speaker_id) + + UserPermissions.check!(user_id, :add, :speaker) + + speaker = Repo.get!(Speaker, speaker_id) + changeset = VideoSpeaker.changeset(%VideoSpeaker{speaker_id: speaker.id, video_id: video_id}) + + Multi.new() + |> Multi.insert(:video_speaker, changeset) + |> Multi.insert(:action_add, action_add(user_id, video_id, speaker)) + |> Repo.transaction() + |> case do + {:ok, %{}} -> + Subscriptions.publish_speaker_added(speaker, video_id) + CF.Algolia.VideosIndex.reindex_by_id(video_id) + {:ok, speaker} + + {:error, _, %{errors: errors}, _} -> + if errors[:video] == {"has already been taken", []}, + do: {:error, "Speaker already added to this video"}, + else: {:error, "Failed to add speaker"} + end + end + + @doc """ + Create a new speaker and add it to a video + """ + def create_speaker(_root, %{video_id: video_id, full_name: full_name}, %{ + context: %{user: user} + }) do + user_id = user.id + video_id = String.to_integer(video_id) + + UserPermissions.check!(user_id, :create, :speaker) + + speaker_changeset = Speaker.changeset(%Speaker{}, %{full_name: full_name}) + + Multi.new() + |> Multi.insert(:speaker, speaker_changeset) + |> Multi.run(:video_speaker, fn _repo, %{speaker: speaker} -> + # Insert association between video and speaker + %VideoSpeaker{speaker_id: speaker.id, video_id: video_id} + |> VideoSpeaker.changeset() + |> Repo.insert() + end) + |> Multi.run(:action_create, fn _repo, %{speaker: speaker} -> + Repo.insert(action_create(user_id, speaker)) + end) + |> Multi.run(:action_add, fn _repo, %{speaker: speaker} -> + Repo.insert(action_add(user_id, video_id, speaker)) + end) + |> Repo.transaction() + |> case do + {:ok, %{speaker: speaker}} -> + Subscriptions.publish_speaker_added(speaker, video_id) + CF.Algolia.VideosIndex.reindex_by_id(video_id) + SpeakersIndex.save_object(speaker) + {:ok, speaker} + + {:error, :speaker, changeset, %{}} -> + {:error, "Invalid speaker data"} + + _ -> + {:error, "Failed to create speaker"} + end + end + + @doc """ + Remove a speaker from a video + """ + def remove_speaker_from_video(_root, %{video_id: video_id, speaker_id: speaker_id}, %{ + context: %{user: user} + }) do + user_id = user.id + video_id = String.to_integer(video_id) + speaker_id = String.to_integer(speaker_id) + + speaker = Repo.get!(Speaker, speaker_id) + UserPermissions.check!(user_id, :remove, :speaker) + + video_speaker = %VideoSpeaker{speaker_id: speaker.id, video_id: video_id} + + Multi.new() + |> Multi.delete(:video_speaker, VideoSpeaker.changeset(video_speaker)) + |> Multi.insert(:action_remove, action_remove(user_id, video_id, speaker)) + |> Repo.transaction() + |> case do + {:ok, _} -> + Subscriptions.publish_speaker_removed(speaker_id, video_id) + CF.Algolia.VideosIndex.reindex_by_id(video_id) + {:ok, %{id: speaker_id}} + + {:error, _operation, reason, _changes} -> + {:error, reason} + end + end + + @doc """ + Update an existing speaker + """ + def update_speaker(_root, %{id: id} = args, %{context: %{user: user}}) do + user_id = user.id + speaker = Repo.get!(Speaker, id) + UserPermissions.check!(user_id, :update, :speaker) + + # Remove id from args as it's not part of the changeset + update_args = Map.delete(args, :id) + + speaker + |> Speaker.changeset(update_args) + |> Repo.update() + |> case do + {:ok, updated_speaker} -> + Subscriptions.publish_speaker_updated(updated_speaker, nil) + CF.Algolia.SpeakersIndex.save_object(updated_speaker) + {:ok, updated_speaker} + + {:error, changeset} -> + {:error, "Failed to update speaker"} + end + end + + @doc """ + Restore a removed speaker to a video + """ + def restore_speaker(_root, %{speaker_id: speaker_id, video_id: video_id}, %{ + context: %{user: user} + }) do + user_id = user.id + video_id = String.to_integer(video_id) + speaker_id = String.to_integer(speaker_id) + + speaker = Repo.get!(Speaker, speaker_id) + UserPermissions.check!(user_id, :restore, :speaker) + + video_speaker = %VideoSpeaker{speaker_id: speaker.id, video_id: video_id} + + Multi.new() + |> Multi.insert(:video_speaker, VideoSpeaker.changeset(video_speaker)) + |> Multi.insert(:action_restore, action_restore(user_id, video_id, speaker)) + |> Repo.transaction() + |> case do + {:ok, %{action_restore: action}} -> + Subscriptions.publish_video_history_action(action, video_id) + Subscriptions.publish_speaker_added(speaker, video_id) + CF.Algolia.VideosIndex.reindex_by_id(video_id) + {:ok, speaker} + + {:error, _operation, reason, _changes} -> + {:error, reason} + end + end end diff --git a/apps/cf_graphql/lib/resolvers/statements.ex b/apps/cf_graphql/lib/resolvers/statements.ex index 33f03f66..7154b71e 100644 --- a/apps/cf_graphql/lib/resolvers/statements.ex +++ b/apps/cf_graphql/lib/resolvers/statements.ex @@ -5,8 +5,16 @@ defmodule CF.Graphql.Resolvers.Statements do alias Kaur.Result + alias Ecto.Multi alias DB.Repo alias DB.Schema.Statement + alias CF.Accounts.UserPermissions + alias CF.Actions.ActionCreator + alias CF.Graphql.Subscriptions + alias CF.Algolia.StatementsIndex + alias CF.Statements + + import CF.Actions.ActionCreator, only: [action_remove: 2, action_restore: 2] # Queries @@ -16,4 +24,89 @@ defmodule CF.Graphql.Resolvers.Statements do |> Repo.paginate(page: offset, page_size: limit) |> Result.ok() end + + # Mutations + + def create(_root, args = %{video_id: video_id, text: _text, time: _time}, %{ + context: %{user: user} + }) do + user_id = user.id + UserPermissions.check!(user_id, :create, :statement) + + # Absinthe automatically converts GraphQL camelCase to snake_case + changeset = Statement.changeset(%Statement{video_id: video_id}, args) + + Multi.new() + |> Multi.insert(:statement, changeset) + |> Multi.run(:action_create, fn _repo, %{statement: statement} -> + Repo.insert(ActionCreator.action_create(user_id, statement)) + end) + |> Repo.transaction() + |> case do + {:ok, %{statement: statement}} -> + Subscriptions.publish_statement_added(statement) + StatementsIndex.save_object(statement) + {:ok, statement} + + {:error, _operation, reason, _changes} -> + {:error, reason} + end + end + + def update(_root, args = %{id: id}, %{context: %{user: user}}) when not is_nil(id) do + user_id = user.id + statement = Repo.get_by!(Statement, id: id, is_removed: false) + + case Statements.update!(user_id, statement, args) do + {:ok, updated_statement} -> + Subscriptions.publish_statement_updated(updated_statement) + StatementsIndex.save_object(updated_statement) + {:ok, updated_statement} + + {:error, reason} -> + {:error, reason} + end + end + + def delete(_root, %{id: id}, %{context: %{user: user}}) do + user_id = user.id + UserPermissions.check!(user_id, :remove, :statement) + statement = Repo.get_by!(Statement, id: id, is_removed: false) + + Multi.new() + |> Multi.update(:statement, Statement.changeset_remove(statement)) + |> Multi.insert(:action_remove, action_remove(user_id, statement)) + |> Repo.transaction() + |> case do + {:ok, _} -> + Subscriptions.publish_statement_removed(id, statement.video_id) + StatementsIndex.delete_object(statement) + {:ok, %{id: id}} + + {:error, _operation, reason, _changes} -> + {:error, reason} + end + end + + def restore(_root, %{id: id}, %{context: %{user: user}}) do + user_id = user.id + UserPermissions.check!(user_id, :restore, :statement) + statement = Repo.get_by!(Statement, id: id, is_removed: true) + + Multi.new() + |> Multi.update(:statement, Statement.changeset_restore(statement)) + |> Multi.insert(:action_restore, action_restore(user_id, statement)) + |> Repo.transaction() + |> case do + {:ok, %{action_restore: action, statement: statement}} -> + Subscriptions.publish_video_history_action(action, statement.video_id) + Subscriptions.publish_statement_history_action(action, statement.id) + Subscriptions.publish_statement_added(statement) + StatementsIndex.save_object(statement) + {:ok, statement} + + {:error, _operation, reason, _changes} -> + {:error, reason} + end + end end diff --git a/apps/cf_graphql/lib/resolvers/users.ex b/apps/cf_graphql/lib/resolvers/users.ex index 01aa3c98..4bb5af7c 100644 --- a/apps/cf_graphql/lib/resolvers/users.ex +++ b/apps/cf_graphql/lib/resolvers/users.ex @@ -6,12 +6,15 @@ defmodule CF.Graphql.Resolvers.Users do import Ecto.Query alias CF.Moderation + alias CF.Accounts.UserPermissions alias Kaur.Result alias DB.Repo alias DB.Schema.User alias DB.Schema.UserAction + alias DB.Schema.Vote + alias DB.Schema.Video @doc """ Resolve a user by its id or username @@ -93,6 +96,52 @@ defmodule CF.Graphql.Resolvers.Users do {:ok, CF.Videos.added_by_user(user, page: offset, page_size: limit)} end + @doc """ + Get the number of available flags for this user. + Returns -1 for unlimited flags (publishers) or the remaining number of flags. + Returns 0 if the user cannot flag comments. + """ + def available_flags(user, _, _) do + case UserPermissions.check(user, :flag, :comment) do + {:ok, num_available} -> {:ok, num_available} + {:error, _reason} -> {:ok, 0} + end + end + + @spec votes(nil | %{:id => any(), optional(any()) => any()}, any(), any()) :: {:ok, any()} + @doc """ + Get user's votes on comments for a specific video as a map (commentId => vote value). + Returns all votes if neither video_id nor video_hash_id are provided. + """ + def votes(nil, _, _), do: {:ok, %{}} + + def votes(user, args, _) do + video_id = Map.get(args, :video_id) + video_hash_id = Map.get(args, :video_hash_id) + + query = Vote.user_votes(Vote, user) + + query = + cond do + video_id -> + Vote.video_votes(query, %{id: video_id}) + + video_hash_id -> + Vote.video_votes(query, %{hash_id: video_hash_id}) + + true -> + query + end + + votes = + query + |> select([v], {v.comment_id, v.value}) + |> Repo.all() + |> Enum.into(%{}) + + {:ok, votes} + end + defp filter_by_user_action_direction(query, user, direction) when direction == :all, do: where(query, [a], a.user_id == ^user.id or a.target_user_id == ^user.id) diff --git a/apps/cf_graphql/lib/resolvers/videos.ex b/apps/cf_graphql/lib/resolvers/videos.ex index 044669e1..c44c55f0 100644 --- a/apps/cf_graphql/lib/resolvers/videos.ex +++ b/apps/cf_graphql/lib/resolvers/videos.ex @@ -37,6 +37,13 @@ defmodule CF.Graphql.Resolvers.Videos do end end + def search(_root, %{url: url}, _info) do + case CF.Videos.get_video_by_url(url) do + nil -> {:ok, nil} + video -> {:ok, video} + end + end + # Deprecated: Use paginated_list/3 instead # Keeping for backward compatibility with deprecated all_videos field def list(_root, args, _info) do @@ -102,6 +109,27 @@ defmodule CF.Graphql.Resolvers.Videos do |> Enum.group_by(& &1.video_id) end + def create(_root, %{url: url, unlisted: unlisted}, %{ + context: %{user: user} + }) do + case CF.Videos.get_video_by_url(url) do + nil -> + case CF.Videos.create!(user, url, unlisted: unlisted) do + {:ok, video} -> + {:ok, video} + + {:error, error} when is_binary(error) -> + {:error, error} + + {:error, _reason} -> + {:error, "Failed to create video"} + end + + existing_video -> + {:ok, existing_video} + end + end + def start_automatic_statements_extraction(_root, %{video_id: video_id}, %{ context: %{user: user} }) do @@ -140,6 +168,18 @@ defmodule CF.Graphql.Resolvers.Videos do end end + def shift_statements(_root, %{video_id: video_id, youtube_offset: youtube_offset}, %{ + context: %{user: user} + }) do + case CF.Videos.shift_statements(user, video_id, %{youtube_offset: youtube_offset}) do + {:ok, video} -> + {:ok, video} + + {:error, _reason} -> + {:error, "Failed to shift statements"} + end + end + def set_captions( _root, %{video_id: video_id, captions: %{path: path, content_type: content_type}}, diff --git a/apps/cf_graphql/lib/router.ex b/apps/cf_graphql/lib/router.ex index f52bc86f..1faf6a0a 100644 --- a/apps/cf_graphql/lib/router.ex +++ b/apps/cf_graphql/lib/router.ex @@ -27,7 +27,7 @@ defmodule CF.GraphQLWeb.Router do forward( "/", - Absinthe.Plug, + CF.Graphql.CustomAbsinthePlug, schema: CF.Graphql.Schema, analyze_complexity: true, max_complexity: 400 diff --git a/apps/cf_graphql/lib/schema/schema.ex b/apps/cf_graphql/lib/schema/schema.ex index 7020de55..8c1a858b 100644 --- a/apps/cf_graphql/lib/schema/schema.ex +++ b/apps/cf_graphql/lib/schema/schema.ex @@ -1,6 +1,6 @@ defmodule CF.Graphql.Schema do use Absinthe.Schema - alias CF.Graphql.Resolvers + alias CF.Graphql.{Resolvers, Subscriptions} alias CF.Graphql.Schema.Middleware import_types(Absinthe.Plug.Types) @@ -8,6 +8,7 @@ defmodule CF.Graphql.Schema do import_types(CF.Graphql.Schema.Types.{ AppInfo, Comment, + JSON, Notification, Paginated, Source, @@ -15,6 +16,7 @@ defmodule CF.Graphql.Schema do Statement, Statistics, Subscription, + SubscriptionEvents, UserAction, User, Video, @@ -94,6 +96,39 @@ defmodule CF.Graphql.Schema do field :all_statistics, :statistics do resolve(&Resolvers.Statistics.default/3) end + + @desc "Search for speakers by name" + field :search_speakers, list_of(:speaker) do + arg(:query, non_null(:string)) + arg(:limit, :integer, default_value: 5) + + resolve(&Resolvers.Speakers.search_speakers/3) + end + + @desc "Get a single speaker" + field :speaker, :speaker do + arg(:id, :id) + arg(:slug, :string) + resolve(&Resolvers.Speakers.get/3) + end + + @desc "Get history actions for a video" + field :video_history_actions, list_of(:user_action) do + arg(:video_id, non_null(:id)) + resolve(&Resolvers.History.video_history_actions/3) + end + + @desc "Get history actions for a statement" + field :statement_history_actions, list_of(:user_action) do + arg(:statement_id, non_null(:id)) + resolve(&Resolvers.History.statement_history_actions/3) + end + + @desc "Search for a video by URL. Returns the video if it exists, null otherwise." + field :search_video, :video do + arg(:url, non_null(:string)) + resolve(&Resolvers.Videos.search/3) + end end # Mutation API @@ -131,6 +166,17 @@ defmodule CF.Graphql.Schema do resolve(&Resolvers.Videos.start_automatic_statements_extraction/3) end + @desc "Create a new video. If it already exists, returns the existing video." + field :create_video, :video do + middleware(Middleware.RequireAuthentication) + middleware(Middleware.RequireReputation, 75) + + arg(:url, non_null(:string)) + arg(:unlisted, non_null(:boolean)) + + resolve(&Resolvers.Videos.create/3) + end + field :edit_video, :video do middleware(Middleware.RequireAuthentication) # MIN_REPUTATION_UPDATE_VIDEO @@ -142,6 +188,17 @@ defmodule CF.Graphql.Schema do resolve(&Resolvers.Videos.edit/3) end + @desc "Shift all statements of a video by a given offset" + field :shift_statements, :video do + middleware(Middleware.RequireAuthentication) + middleware(Middleware.RequireReputation, 75) + + arg(:video_id, non_null(:id)) + arg(:youtube_offset, non_null(:integer)) + + resolve(&Resolvers.Videos.shift_statements/3) + end + field :set_video_captions, :video do middleware(Middleware.RequireAuthentication) middleware(Middleware.RequireReputation, 450) @@ -151,5 +208,236 @@ defmodule CF.Graphql.Schema do resolve(&Resolvers.Videos.set_captions/3) end + + @desc "Create a new statement on a video" + field :create_statement, :statement do + middleware(Middleware.RequireAuthentication) + + arg(:video_id, non_null(:id)) + arg(:text, non_null(:string)) + arg(:time, non_null(:integer)) + arg(:speaker_id, :id) + arg(:is_draft, :boolean) + + resolve(&Resolvers.Statements.create/3) + end + + @desc "Update an existing statement" + field :update_statement, :statement do + middleware(Middleware.RequireAuthentication) + + arg(:id, non_null(:id)) + arg(:text, :string) + arg(:time, :integer) + arg(:speaker_id, :id) + arg(:is_draft, :boolean) + + resolve(&Resolvers.Statements.update/3) + end + + @desc "Delete an existing statement" + field :delete_statement, :statement_removed do + middleware(Middleware.RequireAuthentication) + middleware(Middleware.RequireReputation, 75) + + arg(:id, non_null(:id)) + + resolve(&Resolvers.Statements.delete/3) + end + + @desc "Restore a deleted statement" + field :restore_statement, :statement do + middleware(Middleware.RequireAuthentication) + + arg(:id, non_null(:id)) + + resolve(&Resolvers.Statements.restore/3) + end + + @desc "Create a new comment on a statement" + field :create_comment, :comment do + middleware(Middleware.RequireAuthentication) + + arg(:statement_id, non_null(:id)) + arg(:text, :string) + arg(:source, :string) + arg(:reply_to_id, :id) + arg(:approve, :boolean) + + resolve(&Resolvers.Comments.create/3) + end + + @desc "Delete an existing comment" + field :delete_comment, :comment_removed do + middleware(Middleware.RequireAuthentication) + + arg(:id, non_null(:id)) + + resolve(&Resolvers.Comments.delete/3) + end + + @desc "Vote on a comment" + field :vote_comment, :comment do + middleware(Middleware.RequireAuthentication) + + arg(:comment_id, non_null(:id)) + arg(:value, non_null(:integer)) + + resolve(&Resolvers.Comments.vote/3) + end + + @desc "Flag a comment" + field :flag_comment, :comment_flagged do + middleware(Middleware.RequireAuthentication) + + arg(:comment_id, non_null(:id)) + arg(:reason, non_null(:integer)) + + resolve(&Resolvers.Comments.flag/3) + end + + @desc "Add an existing speaker to a video" + field :add_speaker_to_video, :speaker do + middleware(Middleware.RequireAuthentication) + + arg(:video_id, non_null(:id)) + arg(:speaker_id, non_null(:id)) + + resolve(&Resolvers.Speakers.add_speaker_to_video/3) + end + + @desc "Create a new speaker and add it to a video" + field :create_speaker, :speaker do + middleware(Middleware.RequireAuthentication) + + arg(:video_id, non_null(:id)) + arg(:full_name, non_null(:string)) + + resolve(&Resolvers.Speakers.create_speaker/3) + end + + @desc "Remove a speaker from a video" + field :remove_speaker_from_video, :speaker_removed do + middleware(Middleware.RequireAuthentication) + + arg(:video_id, non_null(:id)) + arg(:speaker_id, non_null(:id)) + + resolve(&Resolvers.Speakers.remove_speaker_from_video/3) + end + + @desc "Restore a removed speaker" + field :restore_speaker, :speaker do + middleware(Middleware.RequireAuthentication) + + arg(:speaker_id, non_null(:id)) + arg(:video_id, non_null(:id)) + + resolve(&Resolvers.Speakers.restore_speaker/3) + end + + @desc "Update an existing speaker" + field :update_speaker, :speaker do + middleware(Middleware.RequireAuthentication) + + arg(:id, non_null(:id)) + arg(:full_name, :string) + arg(:title, :string) + arg(:wikidata_item_id, :string) + + resolve(&Resolvers.Speakers.update_speaker/3) + end + end + + subscription do + @desc "Listen for statements added on a video" + field :statement_added, :statement do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for statements updated on a video" + field :statement_updated, :statement do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for statements removed on a video" + field :statement_removed, :statement_removed do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for comments added on a video" + field :comment_added, :comment do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for comments updated on a video" + field :comment_updated, :comment do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for comments removed on a video" + field :comment_removed, :comment_removed do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for comment score changes on a video" + field :comment_score_diff, :comment_score_diff do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for video updates" + field :video_updated, :video do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for speakers added on a video" + field :speaker_added, :speaker do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for speakers updated on a video" + field :speaker_updated, :speaker do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for speakers removed on a video" + field :speaker_removed, :speaker_removed do + arg(:video_id, non_null(:id)) + config(&topic_by_video/2) + end + + @desc "Listen for actions added to a video history" + field :video_history_action_added, :user_action do + arg(:video_id, non_null(:id)) + config(&topic_by_video_history/2) + end + + @desc "Listen for actions added to a statement history" + field :statement_history_action_added, :user_action do + arg(:statement_id, non_null(:id)) + config(&topic_by_statement_history/2) + end + end + + defp topic_by_video(%{video_id: video_id}, _resolution) do + {:ok, topic: Subscriptions.video_topic(video_id)} + end + + defp topic_by_video_history(%{video_id: video_id}, _resolution) do + {:ok, topic: Subscriptions.video_history_topic(video_id)} + end + + defp topic_by_statement_history(%{statement_id: statement_id}, _resolution) do + {:ok, topic: Subscriptions.statement_history_topic(statement_id)} end end diff --git a/apps/cf_graphql/lib/schema/types/comment.ex b/apps/cf_graphql/lib/schema/types/comment.ex index 9e916c50..a7549a53 100644 --- a/apps/cf_graphql/lib/schema/types/comment.ex +++ b/apps/cf_graphql/lib/schema/types/comment.ex @@ -23,7 +23,7 @@ defmodule CF.Graphql.Schema.Types.Comment do @desc "Can be true / false (facts) or null (comment)" field(:approve, :boolean) @desc "Datetime at which the comment has been added" - field(:inserted_at, :string) + field(:inserted_at, non_null(:naive_datetime)) @desc "Score of the comment / fact, based on users votes" field :score, non_null(:integer) do resolve(&Resolvers.Comments.score/3) @@ -38,5 +38,7 @@ defmodule CF.Graphql.Schema.Types.Comment do @desc "If this comment is a reply, this will point toward the comment being replied to" field(:reply_to_id, :id) + @desc "ID of the statement this comment belongs to" + field(:statement_id, :id) end end diff --git a/apps/cf_graphql/lib/schema/types/json.ex b/apps/cf_graphql/lib/schema/types/json.ex new file mode 100644 index 00000000..a258c2d4 --- /dev/null +++ b/apps/cf_graphql/lib/schema/types/json.ex @@ -0,0 +1,38 @@ +defmodule CF.Graphql.Schema.Types.JSON do + @moduledoc """ + JSON scalar type for Absinthe + """ + use Absinthe.Schema.Notation + + scalar :json, name: "JSON" do + description("JSON scalar type") + + serialize(&encode/1) + parse(&decode/1) + end + + defp encode(value) when is_map(value) or is_list(value) do + value + end + + defp encode(value) do + value + end + + defp decode(%Absinthe.Blueprint.Input.String{value: value}) do + case Jason.decode(value) do + {:ok, result} -> {:ok, result} + _ -> :error + end + end + + defp decode(%Absinthe.Blueprint.Input.Null{}) do + {:ok, nil} + end + + defp decode(_) do + :error + end +end + + diff --git a/apps/cf_graphql/lib/schema/types/subscription_events.ex b/apps/cf_graphql/lib/schema/types/subscription_events.ex new file mode 100644 index 00000000..c0b617f6 --- /dev/null +++ b/apps/cf_graphql/lib/schema/types/subscription_events.ex @@ -0,0 +1,42 @@ +defmodule CF.Graphql.Schema.Types.SubscriptionEvents do + @moduledoc """ + GraphQL event payloads for subscription responses. + """ + + use Absinthe.Schema.Notation + + @desc "Reference to a removed speaker" + object :speaker_removed do + field(:id, non_null(:id)) + end + + @desc "Reference to a removed statement" + object :statement_removed do + field(:id, non_null(:id)) + end + + @desc "Reference to a removed comment" + object :comment_removed do + field(:id, non_null(:id)) + field(:statement_id, :id) + field(:reply_to_id, :id) + end + + @desc "Reference to a flagged comment" + object :comment_flagged do + field(:id, non_null(:id)) + end + + @desc "Reference to a comment involved in a score update" + object :comment_reference do + field(:id, non_null(:id)) + field(:statement_id, :id) + field(:reply_to_id, :id) + end + + @desc "A score diff published after a vote" + object :comment_score_diff do + field(:comment, non_null(:comment_reference)) + field(:diff, non_null(:integer)) + end +end diff --git a/apps/cf_graphql/lib/schema/types/user.ex b/apps/cf_graphql/lib/schema/types/user.ex index 047614bb..a5a2e109 100644 --- a/apps/cf_graphql/lib/schema/types/user.ex +++ b/apps/cf_graphql/lib/schema/types/user.ex @@ -5,6 +5,8 @@ defmodule CF.Graphql.Schema.Types.User do use Absinthe.Schema.Notation + import_types(Absinthe.Type.Custom) + import CF.Graphql.Schema.Utils alias CF.Graphql.Schema.Middleware alias CF.Graphql.Resolvers @@ -49,8 +51,16 @@ defmodule CF.Graphql.Schema.Types.User do @desc "A list of user's achievements as a list of integers" field(:achievements, list_of(:integer)) + @desc "User's speaker ID (if any)" + field(:speaker_id, :string) + + @desc "Whether the user is a publisher" + field(:is_publisher, :boolean) + @desc "User's registration datetime" - field(:registered_at, :string, do: fn u, _, _ -> {:ok, u.inserted_at} end) + field(:registered_at, non_null(:naive_datetime), + do: resolve(fn u, _, _ -> {:ok, u.inserted_at} end) + ) @desc "User activity log" field :actions, :activity_log do @@ -94,6 +104,19 @@ defmodule CF.Graphql.Schema.Types.User do arg(:limit, :integer, default_value: 10) resolve(&Resolvers.Users.videos_added/3) end + + @desc "User's votes on comments as a map (commentId => vote value)" + field :votes, :json do + arg(:video_hash_id, :id) + arg(:video_id, :id) + resolve(&Resolvers.Users.votes/3) + end + + @desc "Number of comment flags available for this user (-1 means unlimited)" + field :available_flags, :integer do + middleware(Middleware.RequireAuthentication) + resolve(&Resolvers.Users.available_flags/3) + end end @desc "A paginated list of user actions" diff --git a/apps/cf_graphql/lib/schema/types/user_action.ex b/apps/cf_graphql/lib/schema/types/user_action.ex index bacf5393..1e24004e 100644 --- a/apps/cf_graphql/lib/schema/types/user_action.ex +++ b/apps/cf_graphql/lib/schema/types/user_action.ex @@ -34,7 +34,7 @@ defmodule CF.Graphql.Schema.Types.UserAction do @desc "Entity type" field(:entity, non_null(:string)) @desc "Datetime at which the action has been done" - field(:time, :string, do: resolve(fn a, _, _ -> {:ok, a.inserted_at} end)) + field(:time, :naive_datetime, do: resolve(fn a, _, _ -> {:ok, a.inserted_at} end)) @desc "Reputation change for the author of the action" field(:author_reputation_change, :integer) @desc "Reputation change for the target of the action" diff --git a/apps/cf_graphql/lib/schema/types/video.ex b/apps/cf_graphql/lib/schema/types/video.ex index eacbac4f..0c9cf639 100644 --- a/apps/cf_graphql/lib/schema/types/video.ex +++ b/apps/cf_graphql/lib/schema/types/video.ex @@ -34,7 +34,7 @@ defmodule CF.Graphql.Schema.Types.Video do @desc "Language of the video represented as a two letters locale" field(:language, :string) @desc "Video insert datetime" - field(:inserted_at, :string) + field(:inserted_at, non_null(:naive_datetime)) @desc "Define if video has been added by a partner or a regular user" field(:is_partner, :boolean) @desc "Define if video is unlisted" diff --git a/apps/cf_graphql/lib/subscriptions.ex b/apps/cf_graphql/lib/subscriptions.ex new file mode 100644 index 00000000..0ddb59a7 --- /dev/null +++ b/apps/cf_graphql/lib/subscriptions.ex @@ -0,0 +1,99 @@ +defmodule CF.Graphql.Subscriptions do + @moduledoc """ + Helpers to publish GraphQL subscription events alongside legacy Phoenix channels. + """ + + alias Absinthe.Subscription + alias CF.GraphQLWeb.Endpoint + + @video_topic_prefix "video:" + @video_history_topic_prefix "video_history:" + @statement_history_topic_prefix "statement_history:" + + @spec video_topic(integer()) :: String.t() + def video_topic(video_id), do: "#{@video_topic_prefix}#{video_id}" + + @spec video_history_topic(integer()) :: String.t() + def video_history_topic(video_id), do: "#{@video_history_topic_prefix}#{video_id}" + + @spec statement_history_topic(integer()) :: String.t() + def statement_history_topic(statement_id), + do: "#{@statement_history_topic_prefix}#{statement_id}" + + def publish_statement_added(%{video_id: video_id} = statement) do + publish(:statement_added, statement, video_topic(video_id)) + end + + def publish_statement_updated(%{video_id: video_id} = statement) do + publish(:statement_updated, statement, video_topic(video_id)) + end + + def publish_statement_removed(statement_id, video_id) do + payload = %{id: statement_id} + publish(:statement_removed, payload, video_topic(video_id)) + end + + def publish_comment_added(comment, video_id) do + publish(:comment_added, comment, video_topic(video_id)) + end + + def publish_comment_updated(comment, video_id) do + publish(:comment_updated, comment, video_topic(video_id)) + end + + def publish_comment_removed(comment, video_id) do + payload = %{ + id: comment.id, + statement_id: comment.statement_id, + reply_to_id: comment.reply_to_id + } + + publish(:comment_removed, payload, video_topic(video_id)) + end + + def publish_comment_score_diff(comment, diff, video_id) do + payload = %{ + comment: %{ + id: comment.id, + statement_id: comment.statement_id, + reply_to_id: comment.reply_to_id + }, + diff: diff + } + + publish(:comment_score_diff, payload, video_topic(video_id)) + end + + def publish_video_updated(video) do + publish(:video_updated, video, video_topic(video.id)) + end + + def publish_speaker_added(speaker, video_id) do + publish(:speaker_added, speaker, video_topic(video_id)) + end + + def publish_speaker_updated(speaker, video_id) do + publish(:speaker_updated, speaker, video_topic(video_id)) + end + + def publish_speaker_removed(speaker_id, video_id) do + publish(:speaker_removed, %{id: speaker_id}, video_topic(video_id)) + end + + def publish_video_history_action(action, video_id) do + publish(:video_history_action_added, action, video_history_topic(video_id)) + end + + def publish_statement_history_action(action, statement_id) do + publish(:statement_history_action_added, action, statement_history_topic(statement_id)) + end + + defp publish(event, payload, topic) do + Subscription.publish(Endpoint, payload, [{event, topic}]) + end +end + + + + + diff --git a/apps/cf_graphql/lib/user_socket.ex b/apps/cf_graphql/lib/user_socket.ex new file mode 100644 index 00000000..93885f29 --- /dev/null +++ b/apps/cf_graphql/lib/user_socket.ex @@ -0,0 +1,37 @@ +defmodule CF.GraphQLWeb.UserSocket do + use Phoenix.Socket + use Absinthe.Phoenix.Socket, schema: CF.Graphql.Schema + + alias CF.Authenticator.GuardianImpl + + def connect(params, socket) do + context = build_context(params) + + socket = + socket + |> Absinthe.Phoenix.Socket.put_options(context: context) + + {:ok, socket} + end + + def id(_socket), do: nil + + defp build_context(params) do + with {:ok, token} <- fetch_token(params), + {:ok, user, _claims} <- GuardianImpl.resource_from_token(token) do + %{user: user} + else + _ -> %{} + end + end + + defp fetch_token(%{"token" => token}) when is_binary(token), do: {:ok, token} + defp fetch_token(%{"authorization" => "Bearer " <> token}), do: {:ok, token} + defp fetch_token(%{"Authorization" => "Bearer " <> token}), do: {:ok, token} + defp fetch_token(_), do: :error +end + + + + + diff --git a/apps/cf_graphql/mix.exs b/apps/cf_graphql/mix.exs index b5f3f286..9c514c47 100644 --- a/apps/cf_graphql/mix.exs +++ b/apps/cf_graphql/mix.exs @@ -39,6 +39,7 @@ defmodule CF.Graphql.Mixfile do {:cowboy, "~> 2.0"}, {:corsica, "~> 2.1"}, {:absinthe_plug, "~> 1.5"}, + {:absinthe_phoenix, "~> 2.0"}, {:dataloader, "~> 2.0.2"}, {:kaur, "~> 1.1"}, {:poison, "~> 3.1"}, diff --git a/apps/cf_graphql/test/custom_absinthe_plug_test.exs b/apps/cf_graphql/test/custom_absinthe_plug_test.exs new file mode 100644 index 00000000..b85d12f1 --- /dev/null +++ b/apps/cf_graphql/test/custom_absinthe_plug_test.exs @@ -0,0 +1,26 @@ +defmodule CF.Graphql.CustomAbsinthePlugTest do + use CF.Graphql.ConnCase + + alias CF.Accounts.UserPermissions.PermissionsError + + test "catches exceptions and returns GraphQL error response", %{conn: conn} do + # Mock a resolver that raises PermissionsError + query = """ + mutation { + voteComment(commentId: 1, value: 1) { + id + score + } + } + """ + + # This should trigger exceptions that are caught by our custom plug + # Since we can't easily mock the resolver in this test, we'll just verify + # that our plug compiles and the router uses it correctly + conn = post(conn, "/api", %{query: query}) + + # The response should be successful (200) with JSON content + assert conn.status == 200 + assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + end +end diff --git a/apps/cf_graphql/test/subscriptions/subscription_events_test.exs b/apps/cf_graphql/test/subscriptions/subscription_events_test.exs new file mode 100644 index 00000000..4cce119b --- /dev/null +++ b/apps/cf_graphql/test/subscriptions/subscription_events_test.exs @@ -0,0 +1,62 @@ +defmodule CF.Graphql.SubscriptionEventsTest do + use CF.Graphql.ConnCase, async: false + use Absinthe.Phoenix.SubscriptionTest, schema: CF.Graphql.Schema + + alias CF.Graphql.Subscriptions + + setup do + {:ok, socket} = Phoenix.ChannelTest.connect(CF.GraphQLWeb.UserSocket, %{}) + {:ok, socket} = join_absinthe(socket) + + {:ok, socket: socket} + end + + test "statementAdded publishes payload to subscribers", %{socket: socket} do + ref = + push_doc( + socket, + """ + subscription($videoId: ID!) { + statementAdded(videoId: $videoId) { + id + text + time + isDraft + } + } + """, + variables: %{"videoId" => 123} + ) + + assert_reply(ref, :ok, %{subscriptionId: subscription_id}) + + statement = %DB.Schema.Statement{ + id: 1, + video_id: 123, + text: "Hello world", + time: 10, + is_draft: false + } + + Subscriptions.publish_statement_added(statement) + + assert_push("subscription:data", %{ + result: %{ + data: %{ + "statementAdded" => %{ + "id" => "1", + "text" => "Hello world", + "time" => 10, + "isDraft" => false + } + } + }, + subscriptionId: subscription_id + }) + end +end + + + + + diff --git a/apps/cf_rest_api/lib/channels/comments_channel.ex b/apps/cf_rest_api/lib/channels/comments_channel.ex index d52a6fec..ec5cf583 100644 --- a/apps/cf_rest_api/lib/channels/comments_channel.ex +++ b/apps/cf_rest_api/lib/channels/comments_channel.ex @@ -13,6 +13,7 @@ defmodule CF.RestApi.CommentsChannel do alias CF.Moderation.Flagger alias CF.Comments + alias CF.Graphql.Subscriptions @event_comment_updated "comment_updated" @event_comment_removed "comment_removed" @@ -37,6 +38,7 @@ defmodule CF.RestApi.CommentsChannel do channel = comments_channel(comment.statement.video_id) msg = msg_partial_update(comment, updated_fields) Endpoint.broadcast(channel, @event_comment_updated, msg) + Subscriptions.publish_comment_updated(comment, comment.statement.video_id) end end @@ -47,6 +49,8 @@ defmodule CF.RestApi.CommentsChannel do comment.statement.video_id |> comments_channel() |> Endpoint.broadcast(@event_comment_removed, msg_comment_remove(comment)) + + Subscriptions.publish_comment_removed(comment, comment.statement.video_id) end defp comments_channel(video_id) when is_integer(video_id) do @@ -79,9 +83,11 @@ defmodule CF.RestApi.CommentsChannel do comment = Repo.preload(comment, [:source, :user]) rendered_comment = CommentView.render("comment.json", comment: comment) broadcast!(socket, @event_comment_updated, rendered_comment) + Subscriptions.publish_comment_updated(comment, socket.assigns.video_id) end) broadcast!(socket, "comment_added", CommentView.render("comment.json", comment: comment)) + Subscriptions.publish_comment_added(comment, socket.assigns.video_id) {:reply, :ok, socket} end @@ -95,6 +101,7 @@ defmodule CF.RestApi.CommentsChannel do _ -> broadcast!(socket, @event_comment_removed, msg_comment_remove(comment)) + Subscriptions.publish_comment_removed(comment, socket.assigns.video_id) {:reply, :ok, socket} end end @@ -107,6 +114,7 @@ defmodule CF.RestApi.CommentsChannel do {:ok, comment, vote, prev_value} -> msg = msg_score_diff(comment, value_diff(prev_value, vote.value)) broadcast!(socket, @event_score_diff, msg) + Subscriptions.publish_comment_score_diff(comment, msg.diff, socket.assigns.video_id) {:reply, :ok, socket} {:error, _} -> diff --git a/apps/cf_rest_api/lib/channels/statements_channel.ex b/apps/cf_rest_api/lib/channels/statements_channel.ex index 64f3aba5..f9cdcc29 100644 --- a/apps/cf_rest_api/lib/channels/statements_channel.ex +++ b/apps/cf_rest_api/lib/channels/statements_channel.ex @@ -11,6 +11,7 @@ defmodule CF.RestApi.StatementsChannel do alias CF.Statements alias CF.Accounts.UserPermissions + alias CF.Graphql.Subscriptions alias CF.RestApi.{StatementView, ErrorView} @@ -49,6 +50,7 @@ defmodule CF.RestApi.StatementsChannel do {:ok, %{statement: statement}} -> rendered_statement = StatementView.render("show.json", statement: statement) broadcast!(socket, "statement_added", rendered_statement) + Subscriptions.publish_statement_added(statement) CF.Algolia.StatementsIndex.save_object(statement) {:reply, {:ok, rendered_statement}, socket} @@ -64,6 +66,7 @@ defmodule CF.RestApi.StatementsChannel do {:ok, statement} -> rendered_statement = StatementView.render("show.json", statement: statement) broadcast!(socket, "statement_updated", rendered_statement) + Subscriptions.publish_statement_updated(statement) CF.Algolia.StatementsIndex.save_object(statement) {:reply, :ok, socket} @@ -84,6 +87,7 @@ defmodule CF.RestApi.StatementsChannel do |> case do {:ok, _} -> broadcast!(socket, "statement_removed", %{id: id}) + Subscriptions.publish_statement_removed(id, socket.assigns.video_id) CF.Algolia.StatementsIndex.delete_object(statement) {:reply, :ok, socket} diff --git a/apps/cf_rest_api/lib/channels/video_debate_channel.ex b/apps/cf_rest_api/lib/channels/video_debate_channel.ex index e771003e..59e204f0 100644 --- a/apps/cf_rest_api/lib/channels/video_debate_channel.ex +++ b/apps/cf_rest_api/lib/channels/video_debate_channel.ex @@ -24,6 +24,7 @@ defmodule CF.RestApi.VideoDebateChannel do alias CF.Speakers alias CF.Accounts.UserPermissions alias CF.Notifications.Subscriptions + alias CF.Graphql.Subscriptions, as: GraphqlSubscriptions alias CF.RestApi.{VideoView, SpeakerView, ChangesetView} def join("video_debate:" <> video_hash_id, _payload, socket) do @@ -81,6 +82,7 @@ defmodule CF.RestApi.VideoDebateChannel do |> View.render_one(VideoView, "video.json") broadcast!(socket, "video_updated", %{video: rendered_video}) + GraphqlSubscriptions.publish_video_updated(video) {:reply, :ok, socket} {:error, _} -> @@ -102,6 +104,7 @@ defmodule CF.RestApi.VideoDebateChannel do {:ok, %{}} -> rendered_speaker = SpeakerView.render("show.json", speaker: speaker) broadcast!(socket, "speaker_added", rendered_speaker) + GraphqlSubscriptions.publish_speaker_added(speaker, video_id) CF.Algolia.VideosIndex.reindex_by_id(video_id) {:reply, :ok, socket} @@ -137,6 +140,7 @@ defmodule CF.RestApi.VideoDebateChannel do # Broadcast the speaker rendered_speaker = SpeakerView.render("show.json", speaker: speaker) broadcast!(socket, "speaker_added", rendered_speaker) + GraphqlSubscriptions.publish_speaker_added(speaker, video_id) CF.Algolia.VideosIndex.reindex_by_id(video_id) CF.Algolia.SpeakersIndex.save_object(speaker) {:reply, :ok, socket} @@ -172,6 +176,7 @@ defmodule CF.RestApi.VideoDebateChannel do rendered_speaker = View.render_one(speaker, SpeakerView, "speaker.json") broadcast!(socket, "speaker_updated", rendered_speaker) + GraphqlSubscriptions.publish_speaker_updated(speaker, video_id) CF.Algolia.SpeakersIndex.save_object(speaker) CF.Algolia.VideosIndex.reindex_all_speaker_videos(speaker.id) CF.Algolia.StatementsIndex.reindex_all_speaker_statements(speaker.id) @@ -192,6 +197,7 @@ defmodule CF.RestApi.VideoDebateChannel do do_remove_speaker(socket, speaker) CF.Algolia.VideosIndex.reindex_by_id(socket.assigns.video_id) broadcast!(socket, "speaker_removed", %{id: id}) + GraphqlSubscriptions.publish_speaker_removed(id, socket.assigns.video_id) {:reply, :ok, socket} end @@ -251,6 +257,7 @@ defmodule CF.RestApi.VideoDebateChannel do {:ok, speaker} -> rendered_speaker = View.render_one(speaker, SpeakerView, "speaker.json") broadcast!(socket, "speaker_updated", rendered_speaker) + GraphqlSubscriptions.publish_speaker_updated(speaker, socket.assigns.video_id) _ -> # We don't care about errors here diff --git a/apps/cf_rest_api/lib/channels/video_debate_history_channel.ex b/apps/cf_rest_api/lib/channels/video_debate_history_channel.ex index 9ae54693..1ac47f6b 100644 --- a/apps/cf_rest_api/lib/channels/video_debate_history_channel.ex +++ b/apps/cf_rest_api/lib/channels/video_debate_history_channel.ex @@ -19,6 +19,7 @@ defmodule CF.RestApi.VideoDebateHistoryChannel do alias CF.Accounts.UserPermissions alias CF.VideoDebate.History + alias CF.Graphql.Subscriptions, as: GraphqlSubscriptions alias CF.RestApi.{StatementView, SpeakerView, UserActionView} def join("video_debate_history:" <> video_hash_id, _payload, socket) do @@ -72,6 +73,8 @@ defmodule CF.RestApi.VideoDebateHistoryChannel do |> View.render_one(UserActionView, "user_action.json") broadcast!(socket, "action_added", rendered_action) + GraphqlSubscriptions.publish_video_history_action(action, video_id) + GraphqlSubscriptions.publish_statement_history_action(action, statement.id) # Broadcast statement CF.RestApi.Endpoint.broadcast( @@ -80,6 +83,8 @@ defmodule CF.RestApi.VideoDebateHistoryChannel do StatementView.render("show.json", statement: statement) ) + GraphqlSubscriptions.publish_statement_added(statement) + CF.Algolia.StatementsIndex.save_object(statement) {:reply, :ok, socket} @@ -110,6 +115,7 @@ defmodule CF.RestApi.VideoDebateHistoryChannel do |> View.render_one(UserActionView, "user_action.json") broadcast!(socket, "action_added", rendered_action) + GraphqlSubscriptions.publish_video_history_action(action, video_id) # Broadcast the speaker CF.RestApi.Endpoint.broadcast( "video_debate:#{VideoHashId.encode(video_id)}", @@ -117,6 +123,8 @@ defmodule CF.RestApi.VideoDebateHistoryChannel do SpeakerView.render("show.json", speaker: speaker) ) + GraphqlSubscriptions.publish_speaker_added(speaker, video_id) + CF.Algolia.VideosIndex.reindex_by_id(video_id) {:reply, :ok, socket} diff --git a/apps/cf_rest_api/lib/router.ex b/apps/cf_rest_api/lib/router.ex index 659683e7..5bfa81ed 100644 --- a/apps/cf_rest_api/lib/router.ex +++ b/apps/cf_rest_api/lib/router.ex @@ -23,10 +23,10 @@ defmodule CF.RestApi.Router do # ---- Public endpoints ---- get("/", ApiInfoController, :get) - get("/videos", VideoController, :index) - get("/speakers/:slug_or_id", SpeakerController, :show) - post("/search/video", VideoController, :search) - get("/videos/:video_id/statements", StatementController, :get) + # get("/videos", VideoController, :index) + # get("/speakers/:slug_or_id", SpeakerController, :show) + # post("/search/video", VideoController, :search) + # get("/videos/:video_id/statements", StatementController, :get) get("/newsletter/unsubscribe/:token", UserController, :newsletter_unsubscribe) # ---- Authenticathed endpoints ---- @@ -43,7 +43,7 @@ defmodule CF.RestApi.Router do # Users scope "/users" do post("/", UserController, :create) - post("/request_invitation", UserController, :request_invitation) + # post("/request_invitation", UserController, :request_invitation) get("/username/:username", UserController, :show) scope "/reset_password" do @@ -65,12 +65,12 @@ defmodule CF.RestApi.Router do end end - # Videos - post("/videos", VideoController, :get_or_create) + # # Videos + # post("/videos", VideoController, :get_or_create) - # Moderation - get("/moderation/random", ModerationController, :random) - post("/moderation/feedback", ModerationController, :post_feedback) + # # Moderation + # get("/moderation/random", ModerationController, :random) + # post("/moderation/feedback", ModerationController, :post_feedback) end end diff --git a/apps/cf_rest_api/lib/views/user_view.ex b/apps/cf_rest_api/lib/views/user_view.ex index 788735b6..e8828e40 100644 --- a/apps/cf_rest_api/lib/views/user_view.ex +++ b/apps/cf_rest_api/lib/views/user_view.ex @@ -2,6 +2,7 @@ defmodule CF.RestApi.UserView do use CF.RestApi, :view alias CF.RestApi.UserView + alias CF.Accounts.UserPermissions def render("index_public.json", %{users: users}) do render_many(users, UserView, "public_user.json") @@ -30,6 +31,8 @@ defmodule CF.RestApi.UserView do end def render("user.json", %{user: user}) do + {:ok, available_flags} = UserPermissions.check(user, :flag, :comment) + %{ id: user.id, email: user.email, @@ -43,7 +46,8 @@ defmodule CF.RestApi.UserView do registered_at: user.inserted_at, achievements: user.achievements, is_publisher: user.is_publisher, - speaker_id: user.speaker_id + speaker_id: user.speaker_id, + available_flags: available_flags } end diff --git a/apps/cf_rest_api/mix.exs b/apps/cf_rest_api/mix.exs index 9b359b3c..8ec2ba2f 100644 --- a/apps/cf_rest_api/mix.exs +++ b/apps/cf_rest_api/mix.exs @@ -47,6 +47,7 @@ defmodule CF.RestApi.Mixfile do {:plug_cowboy, "~> 2.7.2"}, # ---- Internal ---- + {:cf_graphql, in_umbrella: true}, {:cf, in_umbrella: true}, {:db, in_umbrella: true} ] diff --git a/apps/db/lib/db_schema/statement.ex b/apps/db/lib/db_schema/statement.ex index 23030d2e..0e4cc8b7 100644 --- a/apps/db/lib/db_schema/statement.ex +++ b/apps/db/lib/db_schema/statement.ex @@ -84,6 +84,24 @@ defmodule DB.Schema.Statement do |> cast_assoc(:speaker) end + @doc """ + Builds a changeset for updating an existing statement. + Makes time optional since it may not be changed in every update. + """ + def changeset_update(struct, params \\ %{}) do + # For updates, only text is required + # time is optional since it may not be updated + struct + |> cast(params, [:text, :time, :speaker_id, :is_draft]) + |> validate_required([:text]) + |> validate_number(:time, + greater_than_or_equal_to: 0, + message: "must be greater than or equal to 0" + ) + |> validate_length(:text, min: 10, max: 280) + |> cast_assoc(:speaker) + end + @doc """ Builds a deletion changeset for `struct` """ diff --git a/apps/db/lib/db_schema/vote.ex b/apps/db/lib/db_schema/vote.ex index ecc7b2ac..baf487ad 100644 --- a/apps/db/lib/db_schema/vote.ex +++ b/apps/db/lib/db_schema/vote.ex @@ -2,7 +2,7 @@ defmodule DB.Schema.Vote do use Ecto.Schema import Ecto.{Changeset, Query} - alias DB.Schema.{User, Statement, Comment} + alias DB.Schema.{User, Statement, Comment, Video} @type vote_value :: -1 | 1 @@ -44,6 +44,19 @@ defmodule DB.Schema.Vote do ) end + def video_votes(query, %{hash_id: video_hash_id}) do + from( + v in query, + join: c in Comment, + on: c.id == v.comment_id, + join: s in Statement, + on: c.statement_id == s.id, + join: vd in Video, + on: s.video_id == vd.id, + where: vd.hash_id == ^video_hash_id + ) + end + def vote_type(user, entity, value) do cond do user.id == entity.user_id -> :self_vote diff --git a/mix.exs b/mix.exs index 9e04332c..9beb0dc6 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule CF.Umbrella.Mixfile do def project do [ - version: "1.2.0", + version: "2.0.0", apps_path: "apps", deps_path: "deps", build_embedded: Mix.env() == :prod, diff --git a/mix.lock b/mix.lock index 689ea523..e2a145cb 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "absinthe": {:hex, :absinthe, "1.7.8", "43443d12ad2b4fcce60e257ac71caf3081f3d5c4ddd5eac63a02628bcaf5b556", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4085df201892a498384f997649aedb37a4ce8a726c170d5b5617ed3bf45d40b"}, + "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.4", "f36999412fbd6a2339abb5b7e24a4cc9492bbc7909d5806deeef83b06f55c508", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "66617ee63b725256ca16264364148b10b19e2ecb177488cd6353584f2e6c1cf3"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, "algoliax": {:hex, :algoliax, "0.7.1", "473746d748c14bd6b8bc34ad7725e331d968cd1c67e3daf35f93c873123fe812", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "399f76a412d20eb81c9977097890514a29d1b8cfffe191a69b660366fe906ed6"}, "approximate_histogram": {:hex, :approximate_histogram, "0.1.1", "198eb36681e763ed4baab6ca0682acec4ef642f60ba272f251d3059052f4f378", [:mix], [], "hexpm", "6cce003d09656efbfe80b4a50f19e6c1f8eaf1424f08e4a96036b340fc67019d"},