From 6b74ecdbb5ed5708162fb7a4857768595d3e6224 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Sat, 6 Dec 2025 18:17:44 +0100 Subject: [PATCH 1/9] chore: Fix compilation warnings --- apps/cf/config/config.exs | 6 ++--- apps/cf/config/dev.exs | 4 ++-- apps/cf/lib/accounts/accounts.ex | 2 +- apps/cf/lib/accounts/username_generator.ex | 4 +++- apps/cf/lib/application.ex | 8 +++---- apps/cf/lib/authenticator/oauth/facebook.ex | 2 +- apps/cf/lib/errors/errors.ex | 23 +------------------ apps/cf/lib/llms/statements_creator.ex | 5 +--- apps/cf/lib/sources/fetcher.ex | 20 ++++++++++++---- .../cf/lib/videos/captions_fetcher_youtube.ex | 1 - apps/cf/lib/videos/videos.ex | 2 +- apps/cf_atom_feed/config/config.exs | 6 ++--- apps/cf_atom_feed/config/dev.exs | 2 +- apps/cf_atom_feed/lib/application.ex | 12 +++++----- apps/cf_atom_feed/lib/router.ex | 12 +++++++++- apps/cf_graphql/config/config.exs | 6 ++--- apps/cf_graphql/config/dev.exs | 2 +- apps/cf_graphql/lib/application.ex | 4 +--- apps/cf_graphql/lib/resolvers/statements.ex | 3 --- apps/cf_graphql/lib/resolvers/users.ex | 3 --- apps/cf_graphql/lib/resolvers/videos.ex | 5 ++-- apps/cf_graphql/lib/schema/schema.ex | 2 -- apps/cf_jobs/config/config.exs | 4 ++-- apps/cf_jobs/config/dev.exs | 2 +- apps/cf_jobs/lib/application.ex | 14 +++++------ apps/cf_jobs/lib/jobs/create_notifications.ex | 6 +++-- apps/cf_jobs/lib/jobs/download_captions.ex | 6 ++--- apps/cf_jobs/lib/jobs/flags.ex | 4 +++- apps/cf_jobs/lib/jobs/moderation.ex | 4 +++- apps/cf_jobs/lib/jobs/reputation.ex | 4 +++- apps/cf_rest_api/config/config.exs | 4 ++-- apps/cf_rest_api/config/dev.exs | 2 +- apps/cf_rest_api/lib/application.ex | 6 ++--- .../lib/channels/video_debate_channel.ex | 13 ++--------- .../lib/controllers/auth_controller.ex | 11 ++++----- .../lib/controllers/user_controller.ex | 1 - apps/cf_rest_api/lib/cors.ex | 3 +++ apps/cf_rest_api/lib/endpoint.ex | 4 ++-- apps/cf_rest_api/lib/security_headers.ex | 2 +- apps/cf_reverse_proxy/config/config.exs | 4 ++-- apps/cf_reverse_proxy/config/dev.exs | 2 +- apps/cf_reverse_proxy/lib/plug.ex | 10 +------- apps/db/config/config.exs | 4 ++-- apps/db/config/dev.exs | 2 +- apps/db/lib/db/application.ex | 6 ++--- apps/db/lib/db/release_tasks.ex | 2 +- apps/db/lib/db_schema/source.ex | 4 ++-- apps/db/lib/db_schema/video.ex | 11 +-------- apps/db/lib/db_type/flag_reason.ex | 2 ++ config/config.exs | 13 ++++++++--- 50 files changed, 128 insertions(+), 156 deletions(-) diff --git a/apps/cf/config/config.exs b/apps/cf/config/config.exs index 7ed63549..157b2d3d 100644 --- a/apps/cf/config/config.exs +++ b/apps/cf/config/config.exs @@ -1,9 +1,9 @@ # This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. +# and its dependencies with the aid of the Config module. # # This configuration file is loaded before any dependency and # is restricted to this project. -use Mix.Config +import Config # General application configuration config :cf, @@ -41,7 +41,7 @@ config :algoliax, application_id: "N5GW2EAIFX" # Import environment specific config -import_config "#{Mix.env()}.exs" +Config.import_config("#{Mix.env()}.exs") config :cf, openai_model: "gpt-4o" diff --git a/apps/cf/config/dev.exs b/apps/cf/config/dev.exs index e77a62c6..ced1bd7b 100644 --- a/apps/cf/config/dev.exs +++ b/apps/cf/config/dev.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config dev_secret = "8C6FsJwjV11d+1WPUIbkEH6gB/VavJrcXWoPLujgpclfxjkLkoNFSjVU9XfeNm6s" @@ -32,5 +32,5 @@ config :cf, CF.Mailer, adapter: Bamboo.LocalAdapter # Import local secrets if any - use wildcard to ignore errors for config <- "*dev.secret.exs" |> Path.expand(__DIR__) |> Path.wildcard() do - import_config config + Config.import_config(config) end diff --git a/apps/cf/lib/accounts/accounts.ex b/apps/cf/lib/accounts/accounts.ex index 70072861..7acf059c 100644 --- a/apps/cf/lib/accounts/accounts.ex +++ b/apps/cf/lib/accounts/accounts.ex @@ -24,7 +24,7 @@ defmodule CF.Accounts do @request_validity 48 * 60 * 60 # Configure Fetching of user picture on Gravatar - @fetch_default_picture Application.get_env(:cf, :fetch_default_user_picture, true) + @fetch_default_picture Application.compile_env(:cf, :fetch_default_user_picture, true) # ---- User creation ---- diff --git a/apps/cf/lib/accounts/username_generator.ex b/apps/cf/lib/accounts/username_generator.ex index fa542358..d0477a89 100644 --- a/apps/cf/lib/accounts/username_generator.ex +++ b/apps/cf/lib/accounts/username_generator.ex @@ -1,4 +1,6 @@ defmodule CF.Accounts.UsernameGenerator do + use Agent + @moduledoc """ Generates a unique username based on user id """ @@ -6,7 +8,7 @@ defmodule CF.Accounts.UsernameGenerator do @name __MODULE__ @username_prefix "NewUser-" - def start_link do + def start_link(_opts \\ []) do Agent.start_link( fn -> Hashids.new( diff --git a/apps/cf/lib/application.ex b/apps/cf/lib/application.ex index 93d1a21c..63c2bc3c 100644 --- a/apps/cf/lib/application.ex +++ b/apps/cf/lib/application.ex @@ -4,16 +4,14 @@ defmodule CF.Application do # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do - import Supervisor.Spec - # Define workers and child supervisors to be supervised children = [ # Other custom supervisors - supervisor(CF.Sources.Fetcher, []), + CF.Sources.Fetcher, # Misc workers - worker(CF.Accounts.UsernameGenerator, []), + CF.Accounts.UsernameGenerator, # Sweep tokens from db - worker(Guardian.DB.Token.SweeperServer, []) + Guardian.DB.Token.SweeperServer ] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html diff --git a/apps/cf/lib/authenticator/oauth/facebook.ex b/apps/cf/lib/authenticator/oauth/facebook.ex index 165eff9f..9308863d 100644 --- a/apps/cf/lib/authenticator/oauth/facebook.ex +++ b/apps/cf/lib/authenticator/oauth/facebook.ex @@ -105,7 +105,7 @@ defmodule CF.Authenticator.OAuth.Facebook do end defp hmac(data, type, key) do - :crypto.hmac(type, key, data) + :crypto.mac(:hmac, type, key, data) end # ---- Private ---- diff --git a/apps/cf/lib/errors/errors.ex b/apps/cf/lib/errors/errors.ex index b693fc34..183bda66 100644 --- a/apps/cf/lib/errors/errors.ex +++ b/apps/cf/lib/errors/errors.ex @@ -36,30 +36,9 @@ defmodule CF.Errors do end @spec do_report(:error | :exit | :throw, any(), [any()], cf_error_params()) :: :ok - def do_report(type, value, stacktrace, params) do + def do_report(type, value, stacktrace, _params) do # Any call to Sentry, Rollbar, etc. should be done here Logger.error("[ERROR][#{type}] #{inspect(value)} - #{inspect(stacktrace)}") :ok end - - defp build_occurence_data(params) do - default_occurrence_data() - |> add_user(params[:user]) - |> Map.merge(params[:data] || %{}) - end - - defp default_occurrence_data() do - %{ - "code_version" => CF.Application.version() - } - end - - defp add_user(base, nil), - do: base - - defp add_user(base, %{id: id, username: username}), - do: Map.merge(base, %{"person" => %{"id" => Integer.to_string(id), "username" => username}}) - - defp add_user(base, %{id: id}), - do: Map.merge(base, %{"person" => %{"id" => Integer.to_string(id)}}) end diff --git a/apps/cf/lib/llms/statements_creator.ex b/apps/cf/lib/llms/statements_creator.ex index c07ff128..401d18f0 100644 --- a/apps/cf/lib/llms/statements_creator.ex +++ b/apps/cf/lib/llms/statements_creator.ex @@ -65,9 +65,6 @@ defmodule CF.LLMs.StatementsCreator do end end - @doc """ - Chunk captions everytime we reach the max caption length - """ defp chunk_captions(captions) do # TODO: Add last captions from previous batch to preserve context Enum.chunk_every(captions, @captions_chunk_size) @@ -138,7 +135,7 @@ defmodule CF.LLMs.StatementsCreator do defp create_statements_from_inputs(statements_inputs, video) do inserted_at = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) - {nb_statements, statements} = + {_nb_statements, statements} = DB.Repo.insert_all( DB.Schema.Statement, Enum.map(statements_inputs, fn %{"text" => text, "time" => time} -> diff --git a/apps/cf/lib/sources/fetcher.ex b/apps/cf/lib/sources/fetcher.ex index 47b53b47..8c1c5c87 100644 --- a/apps/cf/lib/sources/fetcher.ex +++ b/apps/cf/lib/sources/fetcher.ex @@ -10,9 +10,17 @@ defmodule CF.Sources.Fetcher do # ---- Public API ---- - def start_link() do - import Supervisor.Spec + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + type: :supervisor, + restart: :permanent, + shutdown: 500 + } + end + def start_link(_opts \\ []) do Supervisor.start_link( [ :hackney_pool.child_spec( @@ -20,7 +28,7 @@ defmodule CF.Sources.Fetcher do timeout: @request_timeout, max_connections: @max_connections ), - worker(CF.Sources.Fetcher.LinkChecker, []) + CF.Sources.Fetcher.LinkChecker ], strategy: :one_for_all, name: __MODULE__ @@ -75,7 +83,7 @@ defmodule CF.Sources.Fetcher do hackney: [pool: pool_name()] ) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, source_params_from_tree(Floki.parse(body))} + {:ok, source_params_from_tree(Floki.parse_document(body))} {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, :not_found} @@ -137,10 +145,12 @@ defmodule CF.Sources.Fetcher do # Link checker defmodule LinkChecker do + use Agent + @doc """ Agent that record which links are currently fetched """ - def start_link() do + def start_link(_opts \\ []) do Agent.start_link(fn -> MapSet.new() end, name: Fetcher.link_checker_name()) end diff --git a/apps/cf/lib/videos/captions_fetcher_youtube.ex b/apps/cf/lib/videos/captions_fetcher_youtube.ex index 03ba4a5b..7d7a6816 100644 --- a/apps/cf/lib/videos/captions_fetcher_youtube.ex +++ b/apps/cf/lib/videos/captions_fetcher_youtube.ex @@ -7,7 +7,6 @@ defmodule CF.Videos.CaptionsFetcherYoutube do @behaviour CF.Videos.CaptionsFetcher require Logger - import SweetXml @user_agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/135.0" diff --git a/apps/cf/lib/videos/videos.ex b/apps/cf/lib/videos/videos.ex index e975a42f..e1eed22a 100644 --- a/apps/cf/lib/videos/videos.ex +++ b/apps/cf/lib/videos/videos.ex @@ -21,7 +21,7 @@ defmodule CF.Videos do alias CF.Accounts.UserPermissions alias CF.Videos.MetadataFetcher - @captions_fetcher Application.get_env(:cf, :captions_fetcher) + @captions_fetcher Application.compile_env(:cf, :captions_fetcher, nil) @doc """ TODO with_speakers param is only required by REST API diff --git a/apps/cf_atom_feed/config/config.exs b/apps/cf_atom_feed/config/config.exs index 1027be97..e7fe075a 100644 --- a/apps/cf_atom_feed/config/config.exs +++ b/apps/cf_atom_feed/config/config.exs @@ -1,9 +1,9 @@ # This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. +# and its dependencies with the aid of the Config module. # # This configuration file is loaded before any dependency and # is restricted to this project. -use Mix.Config +import Config # General application configuration config :cf_atom_feed, @@ -20,4 +20,4 @@ config :db, DB.Repo, pool_size: 1 # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. -import_config "#{Mix.env()}.exs" +Config.import_config("#{Mix.env()}.exs") diff --git a/apps/cf_atom_feed/config/dev.exs b/apps/cf_atom_feed/config/dev.exs index ab729319..243ae932 100644 --- a/apps/cf_atom_feed/config/dev.exs +++ b/apps/cf_atom_feed/config/dev.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" diff --git a/apps/cf_atom_feed/lib/application.ex b/apps/cf_atom_feed/lib/application.ex index 087c5585..41e416eb 100644 --- a/apps/cf_atom_feed/lib/application.ex +++ b/apps/cf_atom_feed/lib/application.ex @@ -4,14 +4,14 @@ defmodule CF.AtomFeed.Application do # See https://hexdocs.pm/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do - import Supervisor.Spec - - children = [] config = Application.get_env(:cf_atom_feed, CF.AtomFeed.Router) - if config[:cowboy] do - children = [supervisor(CF.AtomFeed.Router, []) | children] - end + children = + if config[:cowboy] do + [CF.AtomFeed.Router] + else + [] + end # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options diff --git a/apps/cf_atom_feed/lib/router.ex b/apps/cf_atom_feed/lib/router.ex index 009adae0..bc0a7ebc 100644 --- a/apps/cf_atom_feed/lib/router.ex +++ b/apps/cf_atom_feed/lib/router.ex @@ -6,7 +6,17 @@ defmodule CF.AtomFeed.Router do plug(:match) plug(:dispatch) - def start_link do + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + type: :worker, + restart: :permanent, + shutdown: 500 + } + end + + def start_link(_opts \\ []) do config = Application.get_env(:cf_atom_feed, CF.AtomFeed.Router) Logger.info("Running CF.AtomFeed.Router with cowboy on port #{config[:cowboy][:port]}") Plug.Cowboy.http(CF.AtomFeed.Router, [], config[:cowboy]) diff --git a/apps/cf_graphql/config/config.exs b/apps/cf_graphql/config/config.exs index 2ba311b6..7c21a153 100644 --- a/apps/cf_graphql/config/config.exs +++ b/apps/cf_graphql/config/config.exs @@ -1,9 +1,9 @@ # This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. +# and its dependencies with the aid of the Config module. # # This configuration file is loaded before any dependency and # is restricted to this project. -use Mix.Config +import Config # General application configuration config :cf_graphql, @@ -28,4 +28,4 @@ config :db, DB.Repo, pool_size: 5 # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. -import_config "#{Mix.env()}.exs" +Config.import_config("#{Mix.env()}.exs") diff --git a/apps/cf_graphql/config/dev.exs b/apps/cf_graphql/config/dev.exs index a5d597d6..8471d424 100644 --- a/apps/cf_graphql/config/dev.exs +++ b/apps/cf_graphql/config/dev.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config config :cf_graphql, CF.GraphQLWeb.Endpoint, http: [port: 4002], diff --git a/apps/cf_graphql/lib/application.ex b/apps/cf_graphql/lib/application.ex index 64ca65a6..9da7939e 100644 --- a/apps/cf_graphql/lib/application.ex +++ b/apps/cf_graphql/lib/application.ex @@ -4,14 +4,12 @@ defmodule CF.Graphql.Application do # See https://hexdocs.pm/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do - import Supervisor.Spec - # Define workers and child supervisors to be supervised children = [ # Start the PubSub system {Phoenix.PubSub, name: CF.Graphql.PubSub}, # Start the endpoint when the application starts - supervisor(CF.GraphQLWeb.Endpoint, []) + {CF.GraphQLWeb.Endpoint, []} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/apps/cf_graphql/lib/resolvers/statements.ex b/apps/cf_graphql/lib/resolvers/statements.ex index bea634d5..33f03f66 100644 --- a/apps/cf_graphql/lib/resolvers/statements.ex +++ b/apps/cf_graphql/lib/resolvers/statements.ex @@ -5,9 +5,6 @@ defmodule CF.Graphql.Resolvers.Statements do alias Kaur.Result - import Ecto.Query - import Absinthe.Resolution.Helpers, only: [batch: 3] - alias DB.Repo alias DB.Schema.Statement diff --git a/apps/cf_graphql/lib/resolvers/users.ex b/apps/cf_graphql/lib/resolvers/users.ex index ce90e4ff..01aa3c98 100644 --- a/apps/cf_graphql/lib/resolvers/users.ex +++ b/apps/cf_graphql/lib/resolvers/users.ex @@ -39,9 +39,6 @@ defmodule CF.Graphql.Resolvers.Users do {:ok, user} end - @doc """ - Get logged in user - """ def get_logged_in(_, _, _) do {:ok, nil} end diff --git a/apps/cf_graphql/lib/resolvers/videos.ex b/apps/cf_graphql/lib/resolvers/videos.ex index 27d8b094..044669e1 100644 --- a/apps/cf_graphql/lib/resolvers/videos.ex +++ b/apps/cf_graphql/lib/resolvers/videos.ex @@ -37,7 +37,8 @@ defmodule CF.Graphql.Resolvers.Videos do end end - @deprecated "Use paginated_list/3" + # Deprecated: Use paginated_list/3 instead + # Keeping for backward compatibility with deprecated all_videos field def list(_root, args, _info) do Video |> Video.query_list(Map.get(args, :filters, []), args[:limit]) @@ -126,7 +127,7 @@ defmodule CF.Graphql.Resolvers.Videos do |> Ecto.Multi.update(:video, fn _repo -> changeset end) - |> Ecto.Multi.run(:action, fn _repo, %{video: video} -> + |> Ecto.Multi.run(:action, fn _repo, %{video: _video} -> Repo.insert(CF.Actions.ActionCreator.action_update(user.id, changeset)) end) |> Repo.transaction() diff --git a/apps/cf_graphql/lib/schema/schema.ex b/apps/cf_graphql/lib/schema/schema.ex index e30a7a33..7020de55 100644 --- a/apps/cf_graphql/lib/schema/schema.ex +++ b/apps/cf_graphql/lib/schema/schema.ex @@ -2,7 +2,6 @@ defmodule CF.Graphql.Schema do use Absinthe.Schema alias CF.Graphql.Resolvers alias CF.Graphql.Schema.Middleware - import Absinthe.Resolution.Helpers, only: [dataloader: 1] import_types(Absinthe.Plug.Types) @@ -44,7 +43,6 @@ defmodule CF.Graphql.Schema do query do @desc "[Deprecated] Get all videos" @deprecated "Please update to the paginated version (videos). This will be removed in 0.9." - @since "0.8.16" field :all_videos, list_of(:video) do arg(:filters, :video_filter) arg(:limit, :integer) diff --git a/apps/cf_jobs/config/config.exs b/apps/cf_jobs/config/config.exs index 660da45d..335d5c94 100644 --- a/apps/cf_jobs/config/config.exs +++ b/apps/cf_jobs/config/config.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config # Configure scheduler config :cf_jobs, CF.Jobs.Scheduler, @@ -52,4 +52,4 @@ config :cf_jobs, CF.Jobs.Scheduler, config :db, DB.Repo, pool_size: 3 # Import environment specific config -import_config "#{Mix.env()}.exs" +Config.import_config("#{Mix.env()}.exs") diff --git a/apps/cf_jobs/config/dev.exs b/apps/cf_jobs/config/dev.exs index d2d855e6..becde769 100644 --- a/apps/cf_jobs/config/dev.exs +++ b/apps/cf_jobs/config/dev.exs @@ -1 +1 @@ -use Mix.Config +import Config diff --git a/apps/cf_jobs/lib/application.ex b/apps/cf_jobs/lib/application.ex index de190a0b..50773c41 100644 --- a/apps/cf_jobs/lib/application.ex +++ b/apps/cf_jobs/lib/application.ex @@ -4,8 +4,6 @@ defmodule CF.Jobs.Application do # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do - import Supervisor.Spec - # Wait 10s before starting to give some time for the migrations to run :timer.sleep(1000) @@ -14,18 +12,18 @@ defmodule CF.Jobs.Application do # Define workers and child supervisors to be supervised children = [ # Jobs - worker(CF.Jobs.Reputation, []), - worker(CF.Jobs.Flags, []), - worker(CF.Jobs.Moderation, []), - worker(CF.Jobs.CreateNotifications, []), - worker(CF.Jobs.DownloadCaptions, []) + CF.Jobs.Reputation, + CF.Jobs.Flags, + CF.Jobs.Moderation, + CF.Jobs.CreateNotifications, + CF.Jobs.DownloadCaptions ] # Do not start scheduler in tests children = if env == :test or Application.get_env(:cf, :disable_scheduler), do: children, - else: children ++ [worker(CF.Jobs.Scheduler, [])] + else: children ++ [CF.Jobs.Scheduler] opts = [strategy: :one_for_one, name: CF.Jobs.Supervisor] Supervisor.start_link(children, opts) diff --git a/apps/cf_jobs/lib/jobs/create_notifications.ex b/apps/cf_jobs/lib/jobs/create_notifications.ex index 2605dbb6..40206d2d 100644 --- a/apps/cf_jobs/lib/jobs/create_notifications.ex +++ b/apps/cf_jobs/lib/jobs/create_notifications.ex @@ -1,4 +1,6 @@ defmodule CF.Jobs.CreateNotifications do + use GenServer + @moduledoc """ Eat `UserAction` items, digest them using `Subscriptions` and poop notifications in Database. @@ -41,8 +43,8 @@ defmodule CF.Jobs.CreateNotifications do @spec name() :: :create_notifications def name, do: @name - @spec start_link() :: :ignore | {:error, any()} | {:ok, pid()} - def start_link() do + @spec start_link(any()) :: :ignore | {:error, any()} | {:ok, pid()} + def start_link(_opts \\ []) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end diff --git a/apps/cf_jobs/lib/jobs/download_captions.ex b/apps/cf_jobs/lib/jobs/download_captions.ex index ce4db1e4..7061c373 100644 --- a/apps/cf_jobs/lib/jobs/download_captions.ex +++ b/apps/cf_jobs/lib/jobs/download_captions.ex @@ -1,4 +1,6 @@ defmodule CF.Jobs.DownloadCaptions do + use GenServer + @behaviour CF.Jobs.Job require Logger @@ -7,16 +9,14 @@ defmodule CF.Jobs.DownloadCaptions do alias DB.Repo alias DB.Schema.Video alias DB.Schema.VideoCaption - alias DB.Schema.UsersActionsReport @name :download_captions - @analyser_id UsersActionsReport.analyser_id(@name) # --- Client API --- def name, do: @name - def start_link() do + def start_link(_opts \\ []) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end diff --git a/apps/cf_jobs/lib/jobs/flags.ex b/apps/cf_jobs/lib/jobs/flags.ex index 443ed193..d9064247 100644 --- a/apps/cf_jobs/lib/jobs/flags.ex +++ b/apps/cf_jobs/lib/jobs/flags.ex @@ -1,4 +1,6 @@ defmodule CF.Jobs.Flags do + use GenServer + @moduledoc """ Analyse flags periodically to report innapropriate content @@ -26,7 +28,7 @@ defmodule CF.Jobs.Flags do def name, do: @name - def start_link() do + def start_link(_opts \\ []) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end diff --git a/apps/cf_jobs/lib/jobs/moderation.ex b/apps/cf_jobs/lib/jobs/moderation.ex index 847cb64a..ba042a09 100644 --- a/apps/cf_jobs/lib/jobs/moderation.ex +++ b/apps/cf_jobs/lib/jobs/moderation.ex @@ -1,4 +1,6 @@ defmodule CF.Jobs.Moderation do + use GenServer + @moduledoc """ This job analyze moderation feebacks and ban or unreport comments accordingly. @@ -30,7 +32,7 @@ defmodule CF.Jobs.Moderation do def name, do: @name - def start_link() do + def start_link(_opts \\ []) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end diff --git a/apps/cf_jobs/lib/jobs/reputation.ex b/apps/cf_jobs/lib/jobs/reputation.ex index 672fd5ec..ed1d7dcc 100644 --- a/apps/cf_jobs/lib/jobs/reputation.ex +++ b/apps/cf_jobs/lib/jobs/reputation.ex @@ -1,4 +1,6 @@ defmodule CF.Jobs.Reputation do + use GenServer + @moduledoc """ Updates a user reputation periodically, verifying at the same time that the maximum reputation gain per day quota is respected. @@ -28,7 +30,7 @@ defmodule CF.Jobs.Reputation do def name, do: @name - def start_link() do + def start_link(_opts \\ []) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end diff --git a/apps/cf_rest_api/config/config.exs b/apps/cf_rest_api/config/config.exs index a6e4b34c..094247b8 100644 --- a/apps/cf_rest_api/config/config.exs +++ b/apps/cf_rest_api/config/config.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config config :cf_rest_api, cors_origins: [] @@ -14,4 +14,4 @@ config :cf_rest_api, CF.RestApi.Endpoint, config :db, DB.Repo, pool_size: 10 # Import environment specific config -import_config "#{Mix.env()}.exs" +Config.import_config("#{Mix.env()}.exs") diff --git a/apps/cf_rest_api/config/dev.exs b/apps/cf_rest_api/config/dev.exs index c944961f..d83561b2 100644 --- a/apps/cf_rest_api/config/dev.exs +++ b/apps/cf_rest_api/config/dev.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config dev_secret = "8C6FsJwjV11d+1WPUIbkEH6gB/VavJrcXWoPLujgpclfxjkLkoNFSjVU9XfeNm6s" diff --git a/apps/cf_rest_api/lib/application.ex b/apps/cf_rest_api/lib/application.ex index 33368ed0..f2753f1f 100644 --- a/apps/cf_rest_api/lib/application.ex +++ b/apps/cf_rest_api/lib/application.ex @@ -2,16 +2,14 @@ defmodule CF.RestApi.Application do use Application def start(_type, _args) do - import Supervisor.Spec - # Define workers and child supervisors to be supervised children = [ # Start the PubSub system {Phoenix.PubSub, name: CF.RestApi.PubSub}, # Start the endpoint when the application starts - supervisor(CF.RestApi.Endpoint, []), + {CF.RestApi.Endpoint, []}, # Presence to track number of connected users to a channel - supervisor(CF.RestApi.Presence, []) + {CF.RestApi.Presence, []} ] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 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 a6228ddd..e771003e 100644 --- a/apps/cf_rest_api/lib/channels/video_debate_channel.ex +++ b/apps/cf_rest_api/lib/channels/video_debate_channel.ex @@ -49,7 +49,7 @@ defmodule CF.RestApi.VideoDebateChannel do end @doc """ - Register a public connection in presence tracker + Register a connection in presence tracker (public or user) """ def handle_info(:after_join, socket = %{assigns: %{user_id: nil}}) do push(socket, "presence_state", Presence.list(socket)) @@ -57,9 +57,6 @@ defmodule CF.RestApi.VideoDebateChannel do {:noreply, socket} end - @doc """ - Register a user connection in presence tracker - """ def handle_info(:after_join, socket = %{assigns: %{user_id: user_id}}) do push(socket, "presence_state", Presence.list(socket)) {:ok, _} = Presence.track(socket, :users, %{user_id: user_id}) @@ -71,7 +68,7 @@ defmodule CF.RestApi.VideoDebateChannel do end @doc """ - Shift all video's statements + Handle authenticated channel messages """ def handle_in_authenticated!("shift_statements", offsets, socket) do user = Repo.get(DB.Schema.User, socket.assigns.user_id) @@ -91,9 +88,6 @@ defmodule CF.RestApi.VideoDebateChannel do end end - @doc """ - Add an existing speaker to the video - """ def handle_in_authenticated!("new_speaker", %{"id" => id}, socket) do %{user_id: user_id, video_id: video_id} = socket.assigns UserPermissions.check!(user_id, :add, :speaker) @@ -118,9 +112,6 @@ defmodule CF.RestApi.VideoDebateChannel do end end - @doc """ - Create a new speaker and add it to the video - """ def handle_in_authenticated!("new_speaker", params, socket) do %{user_id: user_id, video_id: video_id} = socket.assigns UserPermissions.check!(user_id, :create, :speaker) diff --git a/apps/cf_rest_api/lib/controllers/auth_controller.ex b/apps/cf_rest_api/lib/controllers/auth_controller.ex index 6831c979..14fe33da 100644 --- a/apps/cf_rest_api/lib/controllers/auth_controller.ex +++ b/apps/cf_rest_api/lib/controllers/auth_controller.ex @@ -22,7 +22,7 @@ defmodule CF.RestApi.AuthController do @err_invalid_email_password "invalid_email_password" @doc """ - Auth with identity (name|email + password) + Handle authentication callback for identity or OAuth providers """ def callback(conn, %{"provider" => "identity", "email" => email, "password" => password}) do case Authenticator.get_user_for_email_or_name_password(email, password) do @@ -37,11 +37,10 @@ defmodule CF.RestApi.AuthController do end end - @doc """ - Auth with third party provider (OAuth, only Facebook for now). - If user is connected -> associate account with third party - If not -> get or create account from third party infos - """ + def callback(_conn, %{"provider" => provider}) when provider in ["facebook"] do + # This will be handled by the OAuth callback below + end + def callback(conn, params = %{"provider" => provider_str, "code" => code}) do user = GuardianImpl.Plug.current_resource(conn) provider = provider_atom!(provider_str) diff --git a/apps/cf_rest_api/lib/controllers/user_controller.ex b/apps/cf_rest_api/lib/controllers/user_controller.ex index 60a22a71..47fc73d2 100644 --- a/apps/cf_rest_api/lib/controllers/user_controller.ex +++ b/apps/cf_rest_api/lib/controllers/user_controller.ex @@ -8,7 +8,6 @@ defmodule CF.RestApi.UserController do alias CF.Accounts.Invitations alias CF.Accounts.UserPermissions alias CF.Authenticator.GuardianImpl - alias CF.RestApi.UserView alias Kaur.Result diff --git a/apps/cf_rest_api/lib/cors.ex b/apps/cf_rest_api/lib/cors.ex index d594f9c0..096c2ca1 100644 --- a/apps/cf_rest_api/lib/cors.ex +++ b/apps/cf_rest_api/lib/cors.ex @@ -9,4 +9,7 @@ defmodule CF.RestApi.CORS do origin in origins end end + + @spec check_origin(Plug.Conn.t(), String.t()) :: boolean() + def check_origin(_conn, origin), do: check_origin(origin) end diff --git a/apps/cf_rest_api/lib/endpoint.ex b/apps/cf_rest_api/lib/endpoint.ex index 2a7d3acf..ca00bf49 100644 --- a/apps/cf_rest_api/lib/endpoint.ex +++ b/apps/cf_rest_api/lib/endpoint.ex @@ -3,7 +3,7 @@ defmodule CF.RestApi.Endpoint do socket("/socket", CF.RestApi.UserSocket, websocket: true, longpoll: false) - if Application.get_env(:arc, :storage) == Arc.Storage.Local, + if Application.compile_env(:arc, :storage, nil) == Arc.Storage.Local, do: plug(Plug.Static, at: "/resources", from: "./resources", gzip: false) plug(Plug.RequestId) @@ -14,7 +14,7 @@ defmodule CF.RestApi.Endpoint do Corsica, max_age: 3600, allow_headers: ~w(Accept Content-Type Authorization Origin), - origins: {CF.RestApi.CORS, :check_origin} + origins: {CF.RestApi.CORS, :check_origin, []} ) plug( diff --git a/apps/cf_rest_api/lib/security_headers.ex b/apps/cf_rest_api/lib/security_headers.ex index b6fd7fd6..fa17757c 100644 --- a/apps/cf_rest_api/lib/security_headers.ex +++ b/apps/cf_rest_api/lib/security_headers.ex @@ -1,5 +1,5 @@ defmodule CF.RestApi.SecurityHeaders do - @x_frame_options if Application.get_env(:cf, :env) == :dev, + @x_frame_options if Application.compile_env(:cf, :env, :prod) == :dev, do: "SAMEORIGIN", else: "DENY" diff --git a/apps/cf_reverse_proxy/config/config.exs b/apps/cf_reverse_proxy/config/config.exs index 0f2425fc..16a65f77 100644 --- a/apps/cf_reverse_proxy/config/config.exs +++ b/apps/cf_reverse_proxy/config/config.exs @@ -1,7 +1,7 @@ -use Mix.Config +import Config # Configures the endpoint config :cf_reverse_proxy, port: 5000 # Import environment specific config -import_config "#{Mix.env()}.exs" +Config.import_config("#{Mix.env()}.exs") diff --git a/apps/cf_reverse_proxy/config/dev.exs b/apps/cf_reverse_proxy/config/dev.exs index d2d855e6..becde769 100644 --- a/apps/cf_reverse_proxy/config/dev.exs +++ b/apps/cf_reverse_proxy/config/dev.exs @@ -1 +1 @@ -use Mix.Config +import Config diff --git a/apps/cf_reverse_proxy/lib/plug.ex b/apps/cf_reverse_proxy/lib/plug.ex index 64447300..2eb0b34b 100644 --- a/apps/cf_reverse_proxy/lib/plug.ex +++ b/apps/cf_reverse_proxy/lib/plug.ex @@ -10,14 +10,6 @@ defmodule CF.ReverseProxy.Plug do origins: [~r/(.*)\.captainfact\.io$/] ) - @default_host CF.RestApi.Endpoint - @base_host_regex ~r/^(?rest|graphql|feed)\./ - @subdomains %{ - "graphql" => CF.GraphQLWeb.Endpoint, - "rest" => CF.RestApi.Endpoint, - "feed" => CF.AtomFeed.Router - } - def init(opts), do: opts # See https://github.com/wojtekmach/acme_bank/blob/master/apps/master_proxy/lib/master_proxy/plug.ex @@ -25,7 +17,7 @@ defmodule CF.ReverseProxy.Plug do # https://elixirforum.com/t/umbrella-with-2-phoenix-apps-how-to-forward-request-from-1-to-2-and-vice-versa/1797/18?u=betree # https://github.com/jesseshieh/master_proxy - if Application.get_env(:cf, :env) == :dev do + if Application.compile_env(:cf, :env, :prod) == :dev do # Dev requests are routed through here def call(conn, _) do if conn.request_path == "/status" do diff --git a/apps/db/config/config.exs b/apps/db/config/config.exs index 518d95a0..5c98e8d8 100644 --- a/apps/db/config/config.exs +++ b/apps/db/config/config.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config # General application configuration config :db, @@ -14,4 +14,4 @@ config :db, DB.Repo, ] # Import environment specific config -import_config "#{Mix.env()}.exs" +Config.import_config("#{Mix.env()}.exs") diff --git a/apps/db/config/dev.exs b/apps/db/config/dev.exs index d3d464c2..27e2d4cf 100644 --- a/apps/db/config/dev.exs +++ b/apps/db/config/dev.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config # Configure your database config :db, DB.Repo, diff --git a/apps/db/lib/db/application.ex b/apps/db/lib/db/application.ex index f9ba5589..aa5060b2 100644 --- a/apps/db/lib/db/application.ex +++ b/apps/db/lib/db/application.ex @@ -5,13 +5,11 @@ defmodule DB.Application do require Logger def start(_type, _args) do - import Supervisor.Spec, warn: false - # Define workers and child supervisors to be supervised children = [ # Starts a worker by calling: DB.Worker.start_link(arg1, arg2, arg3) - # worker(DB.Worker, [arg1, arg2, arg3]), - supervisor(DB.Repo, []) + # {DB.Worker, [arg1, arg2, arg3]}, + {DB.Repo, []} ] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html diff --git a/apps/db/lib/db/release_tasks.ex b/apps/db/lib/db/release_tasks.ex index 99ce0d3f..5020d12e 100644 --- a/apps/db/lib/db/release_tasks.ex +++ b/apps/db/lib/db/release_tasks.ex @@ -49,7 +49,7 @@ defmodule DB.ReleaseTasks do init() Application.ensure_all_started(:httpoison) seed_script = Path.join([priv_dir(:db), "repo", "seed_politicians.exs"]) - [{module, _}] = Code.load_file(seed_script) + [{module, _}] = Code.compile_file(seed_script) url = "https://raw.githubusercontent.com/CaptainFact/captain-fact-data/master/Wikidata/data/politicians_born_after_1945_having_a_picture.csv" diff --git a/apps/db/lib/db_schema/source.ex b/apps/db/lib/db_schema/source.ex index 52b1b013..6929dbdc 100644 --- a/apps/db/lib/db_schema/source.ex +++ b/apps/db/lib/db_schema/source.ex @@ -16,7 +16,7 @@ defmodule DB.Schema.Source do @url_max_length 2048 # Allow to add localhost urls as sources during tests - @url_regex if Application.get_env(:db, :env) == :test, + @url_regex if Application.compile_env(:db, :env, :prod) == :test, do: ~r/(^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))|localhost/, else: @@ -65,7 +65,7 @@ defmodule DB.Schema.Source do end defp validate_file_mime_type(:file_mime_type, mime_type) do - if MIME.valid?(mime_type) do + if MIME.extensions(mime_type) != [] do [] else [file_mime_type: "Invalid MIME type"] diff --git a/apps/db/lib/db_schema/video.ex b/apps/db/lib/db_schema/video.ex index fab12df6..df374c0b 100644 --- a/apps/db/lib/db_schema/video.ex +++ b/apps/db/lib/db_schema/video.ex @@ -122,7 +122,7 @@ defmodule DB.Schema.Video do do: "https://www.facebook.com/video.php?v=#{id}" # Add a special case for building test URLs - if Application.get_env(:db, :env) == :test do + if Application.compile_env(:db, :env, :prod) == :test do def build_url(%{youtube_id: id, facebook_id: fb_id}), do: "__TEST__/#{id || fb_id}" end @@ -308,13 +308,4 @@ defmodule DB.Schema.Video do ) end) end - - # Return IDs of videos with at least 3 statements - defp popular_videos_subquery do - Video - |> join(:inner, [v], s in assoc(v, :statements)) - |> select([:id]) - |> group_by([v], v.id) - |> having([v, s], count(s.id) >= 3) - end end diff --git a/apps/db/lib/db_type/flag_reason.ex b/apps/db/lib/db_type/flag_reason.ex index 5a45bac6..7534e481 100644 --- a/apps/db/lib/db_type/flag_reason.ex +++ b/apps/db/lib/db_type/flag_reason.ex @@ -54,6 +54,8 @@ defmodule DB.Type.FlagReason do reason1 == reason2 end + def embed_as(_format), do: :self + # ---- Custom functions ---- @doc """ diff --git a/config/config.exs b/config/config.exs index 129dc422..a7e192ae 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,4 +1,11 @@ -use Mix.Config +import Config -import_config "../apps/*/config/config.exs" -import_config "./*.secret.exs" # TODO should filter by env +# Import all app config files +for config <- "../apps/*/config/config.exs" |> Path.expand(__DIR__) |> Path.wildcard() do + Config.import_config(config) +end + +# Import secret config files +for config <- "./*.secret.exs" |> Path.expand(__DIR__) |> Path.wildcard() do + Config.import_config(config) +end From 695dd70af9c813d602f27f08851067901ca3be07 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Sun, 7 Dec 2025 22:36:30 +0100 Subject: [PATCH 2/9] feat: GraphQL subscriptions --- apps/cf/lib/comments/comments.ex | 17 +- apps/cf/lib/statements/statements.ex | 2 +- apps/cf_graphql/lib/application.ex | 3 +- apps/cf_graphql/lib/endpoint.ex | 3 + apps/cf_graphql/lib/resolvers/comments.ex | 75 ++++++++ apps/cf_graphql/lib/resolvers/statements.ex | 71 ++++++++ apps/cf_graphql/lib/schema/schema.ex | 163 +++++++++++++++++- apps/cf_graphql/lib/schema/types/comment.ex | 4 +- .../lib/schema/types/subscription_events.ex | 38 ++++ apps/cf_graphql/lib/schema/types/user.ex | 6 +- apps/cf_graphql/lib/schema/types/video.ex | 2 +- apps/cf_graphql/lib/subscriptions.ex | 95 ++++++++++ apps/cf_graphql/lib/user_socket.ex | 33 ++++ apps/cf_graphql/mix.exs | 1 + .../subscription_events_test.exs | 58 +++++++ .../lib/channels/comments_channel.ex | 8 + .../lib/channels/statements_channel.ex | 4 + .../lib/channels/video_debate_channel.ex | 7 + .../channels/video_debate_history_channel.ex | 8 + apps/cf_rest_api/mix.exs | 1 + apps/db/lib/db_schema/statement.ex | 18 ++ mix.lock | 1 + 22 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 apps/cf_graphql/lib/schema/types/subscription_events.ex create mode 100644 apps/cf_graphql/lib/subscriptions.ex create mode 100644 apps/cf_graphql/lib/user_socket.ex create mode 100644 apps/cf_graphql/test/subscriptions/subscription_events_test.exs diff --git a/apps/cf/lib/comments/comments.ex b/apps/cf/lib/comments/comments.ex index 15cfd110..7e251f6e 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 && 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_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/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..9dbff1b7 100644 --- a/apps/cf_graphql/lib/resolvers/comments.ex +++ b/apps/cf_graphql/lib/resolvers/comments.ex @@ -3,6 +3,10 @@ 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 def score(comment, _args, _info) do batch({__MODULE__, :comments_scores}, comment.id, fn results -> @@ -18,4 +22,75 @@ 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) + + # Comments.add_comment returns the comment directly or {:error, reason} + case Comments.add_comment(user, video_id, params, source_url) do + {:error, reason} -> + {:error, reason} + + comment -> + # Preload associations for GraphQL response + comment = Repo.preload(comment, [:source, :user, :statement]) + + Subscriptions.publish_comment_added(comment, video_id) + {:ok, comment} + 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 + + # 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/statements.ex b/apps/cf_graphql/lib/resolvers/statements.ex index 33f03f66..01ace38e 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] # Queries @@ -16,4 +24,67 @@ 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 end diff --git a/apps/cf_graphql/lib/schema/schema.ex b/apps/cf_graphql/lib/schema/schema.ex index 7020de55..0faf7390 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) @@ -15,6 +15,7 @@ defmodule CF.Graphql.Schema do Statement, Statistics, Subscription, + SubscriptionEvents, UserAction, User, Video, @@ -151,5 +152,165 @@ 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 "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 + 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/subscription_events.ex b/apps/cf_graphql/lib/schema/types/subscription_events.ex new file mode 100644 index 00000000..95ffb0e5 --- /dev/null +++ b/apps/cf_graphql/lib/schema/types/subscription_events.ex @@ -0,0 +1,38 @@ +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 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..a94688ab 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 @@ -50,7 +52,9 @@ defmodule CF.Graphql.Schema.Types.User do field(:achievements, list_of(:integer)) @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 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..d4dca647 --- /dev/null +++ b/apps/cf_graphql/lib/subscriptions.ex @@ -0,0 +1,95 @@ +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..b4e7a871 --- /dev/null +++ b/apps/cf_graphql/lib/user_socket.ex @@ -0,0 +1,33 @@ +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/subscriptions/subscription_events_test.exs b/apps/cf_graphql/test/subscriptions/subscription_events_test.exs new file mode 100644 index 00000000..6b8b2277 --- /dev/null +++ b/apps/cf_graphql/test/subscriptions/subscription_events_test.exs @@ -0,0 +1,58 @@ +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/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/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"}, From f9b938c1c68553c99da3b1877ff689fcdc267d06 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Sat, 13 Dec 2025 10:07:47 +0100 Subject: [PATCH 3/9] iterate --- apps/cf_graphql/lib/custom_absinthe_plug.ex | 48 +++++++ apps/cf_graphql/lib/resolvers/comments.ex | 10 ++ apps/cf_graphql/lib/resolvers/speakers.ex | 118 ++++++++++++++++++ apps/cf_graphql/lib/resolvers/users.ex | 36 ++++++ apps/cf_graphql/lib/router.ex | 2 +- apps/cf_graphql/lib/schema/schema.ex | 39 ++++++ apps/cf_graphql/lib/schema/types/json.ex | 38 ++++++ .../lib/schema/types/subscription_events.ex | 6 +- apps/cf_graphql/lib/schema/types/user.ex | 7 ++ apps/cf_graphql/lib/subscriptions.ex | 2 + apps/cf_graphql/lib/user_socket.ex | 2 + .../test/custom_absinthe_plug_test.exs | 26 ++++ .../subscription_events_test.exs | 2 + apps/db/lib/db_schema/vote.ex | 15 ++- 14 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 apps/cf_graphql/lib/custom_absinthe_plug.ex create mode 100644 apps/cf_graphql/lib/schema/types/json.ex create mode 100644 apps/cf_graphql/test/custom_absinthe_plug_test.exs 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/resolvers/comments.ex b/apps/cf_graphql/lib/resolvers/comments.ex index 9dbff1b7..76c4971e 100644 --- a/apps/cf_graphql/lib/resolvers/comments.ex +++ b/apps/cf_graphql/lib/resolvers/comments.ex @@ -7,6 +7,7 @@ defmodule CF.Graphql.Resolvers.Comments do 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 -> @@ -90,6 +91,15 @@ defmodule CF.Graphql.Resolvers.Comments do 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 diff --git a/apps/cf_graphql/lib/resolvers/speakers.ex b/apps/cf_graphql/lib/resolvers/speakers.ex index f5a32eec..8dac15f0 100644 --- a/apps/cf_graphql/lib/resolvers/speakers.ex +++ b/apps/cf_graphql/lib/resolvers/speakers.ex @@ -1,5 +1,123 @@ 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] + def picture(speaker, _, _) do {:ok, DB.Type.SpeakerPicture.full_url(speaker, :thumb)} 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 end diff --git a/apps/cf_graphql/lib/resolvers/users.ex b/apps/cf_graphql/lib/resolvers/users.ex index 01aa3c98..ea1ddf77 100644 --- a/apps/cf_graphql/lib/resolvers/users.ex +++ b/apps/cf_graphql/lib/resolvers/users.ex @@ -12,6 +12,8 @@ defmodule CF.Graphql.Resolvers.Users do 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 +95,40 @@ defmodule CF.Graphql.Resolvers.Users do {:ok, CF.Videos.added_by_user(user, page: offset, page_size: limit)} 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/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 0faf7390..979f0884 100644 --- a/apps/cf_graphql/lib/schema/schema.ex +++ b/apps/cf_graphql/lib/schema/schema.ex @@ -8,6 +8,7 @@ defmodule CF.Graphql.Schema do import_types(CF.Graphql.Schema.Types.{ AppInfo, Comment, + JSON, Notification, Paginated, Source, @@ -95,6 +96,14 @@ 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 end # Mutation API @@ -220,6 +229,36 @@ defmodule CF.Graphql.Schema do 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 end subscription do 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 index 95ffb0e5..c0b617f6 100644 --- a/apps/cf_graphql/lib/schema/types/subscription_events.ex +++ b/apps/cf_graphql/lib/schema/types/subscription_events.ex @@ -22,6 +22,11 @@ defmodule CF.Graphql.Schema.Types.SubscriptionEvents do 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)) @@ -35,4 +40,3 @@ defmodule CF.Graphql.Schema.Types.SubscriptionEvents do 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 a94688ab..d5c604a4 100644 --- a/apps/cf_graphql/lib/schema/types/user.ex +++ b/apps/cf_graphql/lib/schema/types/user.ex @@ -98,6 +98,13 @@ 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 end @desc "A paginated list of user actions" diff --git a/apps/cf_graphql/lib/subscriptions.ex b/apps/cf_graphql/lib/subscriptions.ex index d4dca647..d0b59f00 100644 --- a/apps/cf_graphql/lib/subscriptions.ex +++ b/apps/cf_graphql/lib/subscriptions.ex @@ -93,3 +93,5 @@ defmodule CF.Graphql.Subscriptions do end end + + diff --git a/apps/cf_graphql/lib/user_socket.ex b/apps/cf_graphql/lib/user_socket.ex index b4e7a871..94c5a3a8 100644 --- a/apps/cf_graphql/lib/user_socket.ex +++ b/apps/cf_graphql/lib/user_socket.ex @@ -31,3 +31,5 @@ defmodule CF.GraphQLWeb.UserSocket do defp fetch_token(_), do: :error end + + 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 index 6b8b2277..89a443a9 100644 --- a/apps/cf_graphql/test/subscriptions/subscription_events_test.exs +++ b/apps/cf_graphql/test/subscriptions/subscription_events_test.exs @@ -56,3 +56,5 @@ defmodule CF.Graphql.SubscriptionEventsTest do end end + + 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 From ec34495ab7227942e5b629044d32c29735985fd6 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Sat, 13 Dec 2025 11:53:23 +0100 Subject: [PATCH 4/9] iterate --- apps/cf_graphql/lib/resolvers/history.ex | 11 ++ apps/cf_graphql/lib/resolvers/speakers.ex | 108 +++++++++++++++++++- apps/cf_graphql/lib/resolvers/statements.ex | 24 ++++- apps/cf_graphql/lib/schema/schema.ex | 54 ++++++++++ 4 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 apps/cf_graphql/lib/resolvers/history.ex diff --git a/apps/cf_graphql/lib/resolvers/history.ex b/apps/cf_graphql/lib/resolvers/history.ex new file mode 100644 index 00000000..9efddd22 --- /dev/null +++ b/apps/cf_graphql/lib/resolvers/history.ex @@ -0,0 +1,11 @@ +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 +end diff --git a/apps/cf_graphql/lib/resolvers/speakers.ex b/apps/cf_graphql/lib/resolvers/speakers.ex index 8dac15f0..9912122b 100644 --- a/apps/cf_graphql/lib/resolvers/speakers.ex +++ b/apps/cf_graphql/lib/resolvers/speakers.ex @@ -14,12 +14,32 @@ defmodule CF.Graphql.Resolvers.Speakers do alias CF.Graphql.Subscriptions alias CF.Algolia.SpeakersIndex - import CF.Actions.ActionCreator, only: [action_add: 3, action_create: 2] + 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 """ @@ -120,4 +140,90 @@ defmodule CF.Graphql.Resolvers.Speakers do {: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 01ace38e..7154b71e 100644 --- a/apps/cf_graphql/lib/resolvers/statements.ex +++ b/apps/cf_graphql/lib/resolvers/statements.ex @@ -14,7 +14,7 @@ defmodule CF.Graphql.Resolvers.Statements do alias CF.Algolia.StatementsIndex alias CF.Statements - import CF.Actions.ActionCreator, only: [action_remove: 2] + import CF.Actions.ActionCreator, only: [action_remove: 2, action_restore: 2] # Queries @@ -87,4 +87,26 @@ defmodule CF.Graphql.Resolvers.Statements do {: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/schema/schema.ex b/apps/cf_graphql/lib/schema/schema.ex index 979f0884..edf1fe0c 100644 --- a/apps/cf_graphql/lib/schema/schema.ex +++ b/apps/cf_graphql/lib/schema/schema.ex @@ -104,6 +104,19 @@ defmodule CF.Graphql.Schema do 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 end # Mutation API @@ -198,6 +211,15 @@ defmodule CF.Graphql.Schema do 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) @@ -259,6 +281,38 @@ defmodule CF.Graphql.Schema do 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 From e0b490bc8af6f89b4d274c6e418fff94dad14323 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Sat, 13 Dec 2025 12:43:45 +0100 Subject: [PATCH 5/9] iterate --- apps/cf/lib/video_debate/history.ex | 2 ++ apps/cf_graphql/lib/schema/types/user.ex | 3 +++ apps/cf_graphql/lib/schema/types/user_action.ex | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) 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/schema/types/user.ex b/apps/cf_graphql/lib/schema/types/user.ex index d5c604a4..2a0cc858 100644 --- a/apps/cf_graphql/lib/schema/types/user.ex +++ b/apps/cf_graphql/lib/schema/types/user.ex @@ -51,6 +51,9 @@ 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 "User's registration datetime" field(:registered_at, non_null(:naive_datetime), do: resolve(fn u, _, _ -> {:ok, u.inserted_at} end) 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" From 1b04b08b33d63f9257ca3c6e9502c034d7282bb6 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Sat, 13 Dec 2025 17:02:29 +0100 Subject: [PATCH 6/9] iterate --- apps/cf_graphql/lib/resolvers/history.ex | 4 ++++ apps/cf_graphql/lib/resolvers/videos.ex | 12 ++++++++++++ apps/cf_graphql/lib/schema/schema.ex | 17 +++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/apps/cf_graphql/lib/resolvers/history.ex b/apps/cf_graphql/lib/resolvers/history.ex index 9efddd22..3e2593b3 100644 --- a/apps/cf_graphql/lib/resolvers/history.ex +++ b/apps/cf_graphql/lib/resolvers/history.ex @@ -8,4 +8,8 @@ defmodule CF.Graphql.Resolvers.History do 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/videos.ex b/apps/cf_graphql/lib/resolvers/videos.ex index 044669e1..274d0104 100644 --- a/apps/cf_graphql/lib/resolvers/videos.ex +++ b/apps/cf_graphql/lib/resolvers/videos.ex @@ -140,6 +140,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/schema/schema.ex b/apps/cf_graphql/lib/schema/schema.ex index edf1fe0c..52398134 100644 --- a/apps/cf_graphql/lib/schema/schema.ex +++ b/apps/cf_graphql/lib/schema/schema.ex @@ -117,6 +117,12 @@ defmodule CF.Graphql.Schema 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 end # Mutation API @@ -165,6 +171,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) From f0ea6e9b061a6071fb0a59c202a7e1c85610a670 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Sun, 21 Dec 2025 15:01:13 +0100 Subject: [PATCH 7/9] iterate --- apps/cf_graphql/lib/resolvers/history.ex | 2 ++ apps/cf_graphql/lib/resolvers/users.ex | 13 +++++++++++++ apps/cf_graphql/lib/schema/types/user.ex | 9 +++++++++ apps/cf_graphql/lib/subscriptions.ex | 2 ++ apps/cf_graphql/lib/user_socket.ex | 2 ++ .../test/subscriptions/subscription_events_test.exs | 2 ++ apps/cf_rest_api/lib/views/user_view.ex | 6 +++++- 7 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/cf_graphql/lib/resolvers/history.ex b/apps/cf_graphql/lib/resolvers/history.ex index 3e2593b3..9b741c83 100644 --- a/apps/cf_graphql/lib/resolvers/history.ex +++ b/apps/cf_graphql/lib/resolvers/history.ex @@ -13,3 +13,5 @@ defmodule CF.Graphql.Resolvers.History do {:ok, History.statement_history(statement_id)} end end + + diff --git a/apps/cf_graphql/lib/resolvers/users.ex b/apps/cf_graphql/lib/resolvers/users.ex index ea1ddf77..4bb5af7c 100644 --- a/apps/cf_graphql/lib/resolvers/users.ex +++ b/apps/cf_graphql/lib/resolvers/users.ex @@ -6,6 +6,7 @@ defmodule CF.Graphql.Resolvers.Users do import Ecto.Query alias CF.Moderation + alias CF.Accounts.UserPermissions alias Kaur.Result @@ -95,6 +96,18 @@ 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). diff --git a/apps/cf_graphql/lib/schema/types/user.ex b/apps/cf_graphql/lib/schema/types/user.ex index 2a0cc858..a5a2e109 100644 --- a/apps/cf_graphql/lib/schema/types/user.ex +++ b/apps/cf_graphql/lib/schema/types/user.ex @@ -54,6 +54,9 @@ defmodule CF.Graphql.Schema.Types.User do @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, non_null(:naive_datetime), do: resolve(fn u, _, _ -> {:ok, u.inserted_at} end) @@ -108,6 +111,12 @@ defmodule CF.Graphql.Schema.Types.User do 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/subscriptions.ex b/apps/cf_graphql/lib/subscriptions.ex index d0b59f00..0ddb59a7 100644 --- a/apps/cf_graphql/lib/subscriptions.ex +++ b/apps/cf_graphql/lib/subscriptions.ex @@ -95,3 +95,5 @@ end + + diff --git a/apps/cf_graphql/lib/user_socket.ex b/apps/cf_graphql/lib/user_socket.ex index 94c5a3a8..93885f29 100644 --- a/apps/cf_graphql/lib/user_socket.ex +++ b/apps/cf_graphql/lib/user_socket.ex @@ -33,3 +33,5 @@ end + + diff --git a/apps/cf_graphql/test/subscriptions/subscription_events_test.exs b/apps/cf_graphql/test/subscriptions/subscription_events_test.exs index 89a443a9..4cce119b 100644 --- a/apps/cf_graphql/test/subscriptions/subscription_events_test.exs +++ b/apps/cf_graphql/test/subscriptions/subscription_events_test.exs @@ -58,3 +58,5 @@ 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 From f7cf54e62e1e5a4613957c7ba04ae799f36f9773 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Sun, 21 Dec 2025 18:38:32 +0100 Subject: [PATCH 8/9] iterate --- apps/cf/lib/comments/comments.ex | 13 +++++++---- apps/cf/lib/sources/fetcher.ex | 20 ++++++++-------- apps/cf_graphql/lib/resolvers/comments.ex | 28 +++++++++++++++-------- mix.exs | 2 +- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/apps/cf/lib/comments/comments.ex b/apps/cf/lib/comments/comments.ex index 7e251f6e..9aac0b83 100644 --- a/apps/cf/lib/comments/comments.ex +++ b/apps/cf/lib/comments/comments.ex @@ -93,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 @@ -266,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 8c1c5c87..89af13d5 100644 --- a/apps/cf/lib/sources/fetcher.ex +++ b/apps/cf/lib/sources/fetcher.ex @@ -16,7 +16,7 @@ defmodule CF.Sources.Fetcher do start: {__MODULE__, :start_link, [opts]}, type: :supervisor, restart: :permanent, - shutdown: 500 + shutdown: 2000 } end @@ -60,15 +60,15 @@ defmodule CF.Sources.Fetcher do def get_queue, do: Fetcher.LinkChecker.get_queue() - @url_regex ~r/^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/ - defp fetch(url, callback) do - without_domain = Regex.replace(@url_regex, url, "\\1") - path = Regex.replace(~r/\?.+$/, without_domain, "") + uri = URI.parse(url) + + case do_fetch_source_metadata(url, MIME.from_path(uri.path)) do + {:error, err} -> + :error - case do_fetch_source_metadata(url, MIME.from_path(path)) do - {:error, _} -> :error - {:ok, result} -> callback.(result) + {:ok, result} -> + callback.(result) end end @@ -77,13 +77,13 @@ 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/#{CF.Application.version()}"}], follow_redirect: true, max_redirect: 5, hackney: [pool: pool_name()] ) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> - {:ok, source_params_from_tree(Floki.parse_document(body))} + {:ok, source_params_from_tree(Floki.parse_document!(body))} {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, :not_found} diff --git a/apps/cf_graphql/lib/resolvers/comments.ex b/apps/cf_graphql/lib/resolvers/comments.ex index 76c4971e..47debc22 100644 --- a/apps/cf_graphql/lib/resolvers/comments.ex +++ b/apps/cf_graphql/lib/resolvers/comments.ex @@ -41,17 +41,25 @@ defmodule CF.Graphql.Resolvers.Comments do source_url = Map.get(args, :source) - # Comments.add_comment returns the comment directly or {:error, reason} - case Comments.add_comment(user, video_id, params, source_url) do - {:error, reason} -> - {:error, reason} - - comment -> - # Preload associations for GraphQL response - comment = Repo.preload(comment, [:source, :user, :statement]) + # 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 - Subscriptions.publish_comment_added(comment, video_id) - {:ok, comment} + 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 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, From f042f41c1f61e5dfd290450efef6718812018e8e Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Mon, 22 Dec 2025 00:52:22 +0100 Subject: [PATCH 9/9] iterate --- apps/cf_graphql/lib/resolvers/videos.ex | 28 +++++++++++++++++++++++++ apps/cf_graphql/lib/schema/schema.ex | 17 +++++++++++++++ apps/cf_rest_api/lib/router.ex | 20 +++++++++--------- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/apps/cf_graphql/lib/resolvers/videos.ex b/apps/cf_graphql/lib/resolvers/videos.ex index 274d0104..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 diff --git a/apps/cf_graphql/lib/schema/schema.ex b/apps/cf_graphql/lib/schema/schema.ex index 52398134..8c1a858b 100644 --- a/apps/cf_graphql/lib/schema/schema.ex +++ b/apps/cf_graphql/lib/schema/schema.ex @@ -123,6 +123,12 @@ defmodule CF.Graphql.Schema 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 @@ -160,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 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