From 5c888e035772cd90e04f4abcf6721510c2f395f5 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Fri, 15 May 2026 18:54:43 +0300 Subject: [PATCH 1/3] Introduce context-module facades over web layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct cross-module access from the web/transport layer with thin context-module facades, following the Phoenix context pattern. Resolvers and LiveViews now talk to a single domain entry point per concern instead of reaching into 3–5 sibling modules. Changes * Sanbase.AI.ContentCandidates - Drop raw Repo/Ecto.Query usage from ai_description_live.ex - Replace 13 inline queries with 6 context calls (list/4, pending_ids/2, count/2, override_descriptions/2, search_users/1, get_user/1) * Sanbase.Insights - New facade hiding Insight.Post, PostImage, ImageUrl, PopularAuthor, Category, Tag - insight_resolver.ex shrunk from 312 to 141 LOC, now thin transport - Moves resolve_post_images and rate-limited create_post into the context * Sanbase.Billing hides Stripe - 8 new wrappers: refresh_subscription_payment_intent, list_payments, retrieve_coupon, upcoming_invoice, default_payment_instrument, update_default_payment_instrument, delete_default_payment_instrument, create_setup_intent, san_credit_balance - Move transform_payments and choose_default_card into Billing - billing_resolver.ex: drop alias Sanbase.StripeApi; all StripeApi callsites routed through Billing * Sanbase.MetricRegistry facade - New module re-exporting Registry, Sync, ChangeSuggestion, Category, UIMetadata, MetricVersions surface area - metric_display_order_resolver.ex migrated to the facade - Admin LiveView migration intentionally deferred to a follow-up sweep * Sanbase.Changelog + Webinars rename - New Sanbase.Changelog unifies MetricVersions and ProjectVersions changelog reads - changelog_resolver.ex routes both metric and asset changelogs through the facade - Rename Sanbase.Webinar -> Sanbase.Webinars.Webinar across webinar.ex, registration.ex, generic_admin/webinar.ex, webinar_resolver.ex, test/support/factory/factory.ex --- lib/sanbase/accounts/accounts.ex | 37 +++ lib/sanbase/accounts/auth.ex | 220 +++++++++++++++ lib/sanbase/accounts/user_settings.ex | 7 +- lib/sanbase/ai/content_candidates.ex | 198 +++++++++++++ lib/sanbase/alerts/alerts.ex | 43 +++ lib/sanbase/billing/billing.ex | 226 +++++++++++++++ lib/sanbase/changelog/changelog.ex | 33 +++ lib/sanbase/insights/insights.ex | 132 +++++++++ lib/sanbase/metric/metric_registry_facade.ex | 84 ++++++ lib/sanbase/reports/report.ex | 7 +- lib/sanbase/transfers/transfers.ex | 21 ++ lib/sanbase/voting/vote.ex | 61 ++++ lib/sanbase/webinars/registration.ex | 2 +- lib/sanbase/webinars/webinar.ex | 2 +- .../controllers/report_controller.ex | 6 +- lib/sanbase_web/generic_admin/webinar.ex | 2 +- .../graphql/resolvers/billing_resolver.ex | 145 ++-------- .../resolvers/blockchain_address_resolver.ex | 19 +- .../graphql/resolvers/changelog_resolver.ex | 7 +- .../graphql/resolvers/insight_resolver.ex | 265 ++++-------------- .../metric/metric_display_order_resolver.ex | 11 +- .../project/project_transfers_resolver.ex | 15 +- .../graphql/resolvers/report_resolver.ex | 9 +- .../resolvers/sheets_template_resolver.ex | 6 +- .../signals/user_trigger_resolver.ex | 24 +- .../graphql/resolvers/user/auth_resolver.ex | 213 +------------- .../resolvers/user/linked_user_resolver.ex | 8 +- .../graphql/resolvers/user/user_resolver.ex | 17 +- .../graphql/resolvers/vote_resolver.ex | 191 ++----------- .../graphql/resolvers/webinar_resolver.ex | 9 +- .../live/admin/ai_description_live.ex | 218 +------------- test/support/factory/factory.ex | 2 +- 32 files changed, 1226 insertions(+), 1014 deletions(-) create mode 100644 lib/sanbase/accounts/auth.ex create mode 100644 lib/sanbase/ai/content_candidates.ex create mode 100644 lib/sanbase/alerts/alerts.ex create mode 100644 lib/sanbase/changelog/changelog.ex create mode 100644 lib/sanbase/insights/insights.ex create mode 100644 lib/sanbase/metric/metric_registry_facade.ex diff --git a/lib/sanbase/accounts/accounts.ex b/lib/sanbase/accounts/accounts.ex index 951d662af1..f6b0b3ba6c 100644 --- a/lib/sanbase/accounts/accounts.ex +++ b/lib/sanbase/accounts/accounts.ex @@ -3,6 +3,9 @@ defmodule Sanbase.Accounts do alias Sanbase.Accounts.User alias Sanbase.Accounts.EthAccount + @terms_and_conditions_fields [:privacy_policy_accepted, :marketing_accepted] + @profile_fields [:description, :website_url, :twitter_handle, :avatar_url] + def get_user(user_id_or_ids) do User.by_id(user_id_or_ids) end @@ -77,4 +80,38 @@ defmodule Sanbase.Accounts do {:error, reason} end end + + @doc ~s""" + Update the user's profile-visible fields (description, website_url, twitter_handle, + avatar_url). Allowlist enforced regardless of caller-supplied keys. + """ + @spec update_profile(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def update_profile(%User{} = user, attrs) do + User.update(user, Map.take(attrs, @profile_fields)) + end + + @doc ~s""" + Update the user's terms and conditions acceptance flags. Only + `:privacy_policy_accepted` and `:marketing_accepted` are accepted. `nil` values + are dropped so callers can pass partial updates. + """ + @spec update_terms_and_conditions(User.t(), map()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def update_terms_and_conditions(%User{} = user, attrs) do + attrs = + attrs + |> Map.take(@terms_and_conditions_fields) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + + User.update(user, attrs) + end + + @doc ~s""" + Reload the user's `:user_settings` association, bypassing any prior preload. + """ + @spec reload_user_settings(User.t()) :: User.t() + def reload_user_settings(%User{} = user) do + Repo.preload(user, :user_settings, force: true) + end end diff --git a/lib/sanbase/accounts/auth.ex b/lib/sanbase/accounts/auth.ex new file mode 100644 index 0000000000..b19d5ad866 --- /dev/null +++ b/lib/sanbase/accounts/auth.ex @@ -0,0 +1,220 @@ +defmodule Sanbase.Accounts.Auth do + @moduledoc ~s""" + Orchestrates the cross-cutting auth flows: ETH-wallet login, email-link + login (send + verify), and email-change verification. Each function returns + the same shape the GraphQL transport layer renders, so resolvers stay thin. + """ + + import Sanbase.Accounts.EventEmitter, only: [emit_event: 3] + + alias Sanbase.Accounts + alias Sanbase.Accounts.{AccessAttempt, EmailLoginAttempt, EthAccount, Turnstile, User} + alias Sanbase.InternalServices.Ethauth + + require Logger + + @blocked_domains ["burpcollaborator.net"] + + @type jwt_result :: %{access_token: String.t(), refresh_token: String.t(), user: User.t()} + + @spec eth_login(map(), map()) :: + {:ok, jwt_result()} | {:error, [message: String.t()]} + def eth_login( + %{signature: signature, address: address, message_hash: message_hash} = args, + %{device_data: device_data, origin_url: origin_url} + ) do + event_args = %{login_origin: :eth_login, origin_url: origin_url} + + with true <- address_message_hash(address) == message_hash, + true <- Ethauth.valid_signature?(address, signature), + {:ok, user} <- fetch_user(args, EthAccount.by_address(address)), + first_login? <- User.RegistrationState.first_login?(user, "eth_login"), + {:ok, jwt_tokens} <- SanbaseWeb.Guardian.get_jwt_tokens(user, device_data), + {:ok, _, user} <- Accounts.forward_registration(user, "eth_login", event_args) do + user = %{user | first_login: first_login?} + emit_event({:ok, user}, :login_user, event_args) + + {:ok, Map.take(jwt_tokens, [:access_token, :refresh_token]) |> Map.put(:user, user)} + else + {:error, %Ecto.Changeset{} = changeset} -> + Logger.warning("Login failed: #{inspect(changeset)}") + {:error, message: "Wallet Login verification failed"} + + {:error, reason} -> + Logger.warning("Login failed: #{inspect(reason)}") + {:error, message: "Wallet Login verification failed"} + + _ -> + Logger.warning("Login failed: invalid signature") + {:error, message: "Wallet Login verification failed"} + end + end + + @spec send_login_email(map(), map()) :: + {:ok, %{success: true}} | {:error, [message: String.t()]} + def send_login_email( + %{email: email} = args, + %{origin_url: origin_url, origin_host_parts: origin_host_parts, remote_ip: remote_ip} + ) do + remote_ip = Sanbase.Utils.IP.ip_tuple_to_string(remote_ip) + + with :ok <- Turnstile.validate(args[:token], remote_ip), + true <- allowed_email_domain?(email), + true <- allowed_origin?(origin_host_parts, origin_url), + {:ok, %{first_login: first_login} = user} <- + User.find_or_insert_by(:email, email, %{username: args[:username]}), + :ok <- EmailLoginAttempt.check_attempt_limit(user, remote_ip), + {:ok, user} <- User.Email.update_email_token(user, args[:consent]), + {:ok, _res} <- User.Email.send_login_email(user, first_login, origin_host_parts, args), + {:ok, %AccessAttempt{}} <- AccessAttempt.create("email_login", user, remote_ip), + {:ok, _, user} <- + Accounts.forward_registration(user, "send_login_email", %{"origin_url" => origin_url}) do + emit_event({:ok, user}, :send_email_login_link, %{origin_url: origin_url}) + {:ok, %{success: true}} + else + {:error, :invalid_redirect_url, message} -> + Logger.error( + "Login failed: #{message}. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" + ) + + {:error, message: message} + + {:error, :too_many_attempts} -> + Logger.info( + "Login failed: too many login attempts. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" + ) + + {:error, message: "Too many login attempts, try again after a few minutes"} + + {:error, error} when is_binary(error) -> + Logger.error( + "Login failed: #{error}. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" + ) + + {:error, message: error} + + error -> + Logger.error( + "Login failed: unknown error #{inspect(error)}. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" + ) + + {:error, message: "Can't login"} + end + end + + @spec verify_email_login(map(), map()) :: + {:ok, jwt_result()} | {:error, [message: String.t()]} + def verify_email_login(%{token: token, email: email}, %{ + device_data: device_data, + origin_url: origin_url + }) do + args = %{login_origin: :email, origin_url: origin_url} + rand_id = :crypto.strong_rand_bytes(8) |> Base.encode32(case: :lower) |> binary_part(0, 10) + + with _ <- + Logger.info( + "[EmailLoginVerify][#{rand_id}] Start verification for #{email} with token #{String.slice(token, 0..5)}" + ), + {:ok, user} <- User.find_or_insert_by(:email, email), + _ <- Logger.info("[EmailLoginVerify][#{rand_id}] Found user with email #{email}"), + first_login? <- User.RegistrationState.first_login?(user, "email_login_verify"), + _ <- + Logger.info( + "[EmailLoginVerify][#{rand_id}] Start verification for #{email} with token #{String.slice(token, 0..5)}" + ), + true <- User.Email.email_token_valid?(user, token), + _ <- + Logger.info( + "[EmailLoginVerify][#{rand_id}] Verified token #{String.slice(token, 0..5)} for email #{email}" + ), + {:ok, jwt_tokens_map} <- SanbaseWeb.Guardian.get_jwt_tokens(user, device_data), + _ <- + Logger.info("[EmailLoginVerify][#{rand_id}] Created JWT tokens map for #{email}"), + {:ok, user} <- User.Email.mark_email_token_as_validated(user), + _ <- + Logger.info( + "[EmailLoginVerify][#{rand_id}] Marked login token for email #{email} as validated" + ), + {:ok, _, user} <- Accounts.forward_registration(user, "email_login_verify", args), + _ <- + Logger.info( + "[EmailLoginVerify][#{rand_id}] Updated the registration state for email #{email}" + ) do + Logger.info( + "[EmailLoginVerify][#{rand_id} Successfully logged in user with email #{email}]" + ) + + user = %{user | first_login: first_login?} + emit_event({:ok, user}, :login_user, args) + + {:ok, Map.take(jwt_tokens_map, [:access_token, :refresh_token]) |> Map.put(:user, user)} + else + _ -> {:error, message: "Email Login verification failed"} + end + end + + @spec change_email_request(User.t(), String.t(), tuple()) :: + {:ok, %{success: true}} | {:error, [message: String.t()]} + def change_email_request(%User{} = user, email_candidate, remote_ip) do + remote_ip = Sanbase.Utils.IP.ip_tuple_to_string(remote_ip) + + with :ok <- EmailLoginAttempt.check_attempt_limit(user, remote_ip), + {:ok, user} <- User.Email.update_email_candidate(user, email_candidate), + {:ok, _user} <- User.Email.send_verify_email(user), + {:ok, %AccessAttempt{}} <- EmailLoginAttempt.create(user, remote_ip) do + {:ok, %{success: true}} + else + {:error, error} -> + error_msg = "Can't change current user's email to #{email_candidate}" + Logger.info(error_msg <> ". Reason: #{inspect(error)}") + {:error, message: error_msg} + end + end + + @spec verify_email_change(map(), map()) :: + {:ok, jwt_result()} | {:error, [message: String.t()]} + def verify_email_change( + %{token: email_candidate_token, email_candidate: email_candidate}, + %{device_data: device_data} + ) do + with {:ok, user} <- + User.Email.find_by_email_candidate(email_candidate, email_candidate_token), + true <- User.Email.email_candidate_token_valid?(user, email_candidate_token), + {:ok, jwt_tokens} <- SanbaseWeb.Guardian.get_jwt_tokens(user, device_data), + {:ok, user} <- User.Email.update_email_from_email_candidate(user) do + {:ok, Map.take(jwt_tokens, [:access_token, :refresh_token]) |> Map.put(:user, user)} + else + _ -> {:error, message: "Email change verify failed"} + end + end + + defp allowed_origin?(["santiment", "net"] = _hosted_parts, _origin_url), do: true + defp allowed_origin?([_origin_app, "santiment", "net"] = _hosted_parts, _origin_url), do: true + + defp allowed_origin?(_hosted_parts, origin_url), + do: {:error, "Origin header #{origin_url} is not supported."} + + defp allowed_email_domain?(email) do + domain = String.split(email, "@") |> Enum.at(1) + + case domain in @blocked_domains do + true -> {:error, "Email not supported."} + false -> true + end + end + + defp fetch_user(%{address: address}, nil) do + Accounts.create_user_with_eth_address(address) + end + + defp fetch_user(_args, %EthAccount{user_id: user_id}) do + User.by_id(user_id) + end + + defp address_message_hash(address) do + message = "Login in Santiment with address #{address}" + full_message = "\x19Ethereum Signed Message:\n" <> "#{String.length(message)}" <> message + hash = ExKeccak.hash_256(full_message) + "0x" <> Base.encode16(hash, case: :lower) + end +end diff --git a/lib/sanbase/accounts/user_settings.ex b/lib/sanbase/accounts/user_settings.ex index 60c3aa8045..b3a2fff527 100644 --- a/lib/sanbase/accounts/user_settings.ex +++ b/lib/sanbase/accounts/user_settings.ex @@ -4,7 +4,8 @@ defmodule Sanbase.Accounts.UserSettings do alias Sanbase.Accounts.{User, Settings} alias Sanbase.Repo - alias Sanbase.Billing.{Subscription, Product} + alias Sanbase.Billing + alias Sanbase.Billing.Product @self_reset_api_rate_limits_cooldown 90 @@ -145,10 +146,10 @@ defmodule Sanbase.Accounts.UserSettings do # ignoring the rest of the params if is_subscribed_biweekly_report is set to true def update_settings(user, %{is_subscribed_biweekly_report: true} = params) do cond do - Subscription.current_subscription_plan(user.id, Product.product_sanbase()) != "FREE" -> + Billing.user_has_product_access?(user.id, Product.product_sanbase()) -> settings_update(user.id, params) - Subscription.current_subscription_plan(user.id, Product.product_api()) != "FREE" -> + Billing.user_has_product_access?(user.id, Product.product_api()) -> settings_update(user.id, params) true -> diff --git a/lib/sanbase/ai/content_candidates.ex b/lib/sanbase/ai/content_candidates.ex new file mode 100644 index 0000000000..492365d5d0 --- /dev/null +++ b/lib/sanbase/ai/content_candidates.ex @@ -0,0 +1,198 @@ +defmodule Sanbase.AI.ContentCandidates do + @moduledoc ~s""" + Read/write helpers for the admin AI-description LiveView. Encapsulates the + raw Ecto queries against Insight, Chart.Configuration, UserList, and User so + the LiveView never touches `Sanbase.Repo` or the underlying schemas directly. + """ + + import Ecto.Query + + alias Sanbase.Accounts.User + alias Sanbase.Chart.Configuration + alias Sanbase.Insight.Post + alias Sanbase.Repo + alias Sanbase.UserList + + @type entity_type :: :insights | :charts | :screeners | :watchlists + + @doc ~s""" + Page of entities of `type` for `user_id` plus the total count. + """ + @spec list(entity_type(), non_neg_integer(), non_neg_integer(), non_neg_integer()) :: + {list(), non_neg_integer()} + def list(type, user_id, page, page_size) do + limit = page_size + offset = (page - 1) * page_size + + base = list_query(type, user_id) + count = Repo.aggregate(base, :count, :id) + entities = Repo.all(from(q in base, limit: ^limit, offset: ^offset)) + {entities, count} + end + + @doc ~s""" + IDs of entities of `type` for `user_id` that still need an AI description, + returned as `{id, type}` pairs ready for the DescriptionJob queue. + """ + @spec pending_ids(entity_type(), non_neg_integer()) :: [{non_neg_integer(), entity_type()}] + def pending_ids(type, user_id) do + type + |> pending_ids_query(user_id) + |> Repo.all() + |> Enum.map(&{&1, type}) + end + + @doc ~s""" + Total count of (non-deleted) entities of `type` for `user_id`. + """ + @spec count(entity_type(), non_neg_integer()) :: non_neg_integer() + def count(type, user_id) do + Repo.aggregate(count_query(type, user_id), :count, :id) + end + + @doc ~s""" + Copy `ai_description` over the canonical description field for every + non-deleted entity of `type` owned by `user_id`. Returns `{rows_updated, nil}`. + """ + @spec override_descriptions(entity_type(), non_neg_integer()) :: {non_neg_integer(), nil} + def override_descriptions(:insights, user_id) do + Repo.update_all( + from(p in Post, + where: p.user_id == ^user_id and p.is_deleted == false and not is_nil(p.ai_description), + update: [set: [short_desc: p.ai_description]] + ), + [] + ) + end + + def override_descriptions(:charts, user_id) do + Repo.update_all( + from(c in Configuration, + where: c.user_id == ^user_id and c.is_deleted == false and not is_nil(c.ai_description), + update: [set: [description: c.ai_description]] + ), + [] + ) + end + + def override_descriptions(type, user_id) when type in [:screeners, :watchlists] do + screener_flag = type == :screeners + + Repo.update_all( + from(ul in UserList, + where: + ul.user_id == ^user_id and ul.is_deleted == false and ul.is_screener == ^screener_flag and + not is_nil(ul.ai_description), + update: [set: [description: ul.ai_description]] + ), + [] + ) + end + + @doc ~s""" + Search users by numeric ID, or by partial case-insensitive match against + username/email. Returns at most 10 results. + """ + @spec search_users(String.t()) :: [User.t()] + def search_users(query) do + query = String.trim(query) + + case Integer.parse(query) do + {user_id, ""} -> + Repo.all(from(u in User, where: u.id == ^user_id, limit: 10)) + + _ -> + pattern = "%#{String.downcase(query)}%" + + Repo.all( + from(u in User, + where: + fragment("lower(?) LIKE ?", u.username, ^pattern) or + fragment("lower(?) LIKE ?", u.email, ^pattern), + order_by: u.id, + limit: 10 + ) + ) + end + end + + @doc ~s""" + Fetch a user by ID, or `nil` if no such user exists. + """ + @spec get_user(non_neg_integer()) :: User.t() | nil + def get_user(user_id), do: Repo.get(User, user_id) + + # Private queries + + defp list_query(:insights, user_id) do + from(p in Post, + where: p.is_deleted == false and p.user_id == ^user_id, + preload: [:user], + order_by: [desc: p.inserted_at] + ) + end + + defp list_query(:charts, user_id) do + from(c in Configuration, + where: c.is_deleted == false and c.user_id == ^user_id, + preload: [:user], + order_by: [desc: c.inserted_at] + ) + end + + defp list_query(type, user_id) when type in [:screeners, :watchlists] do + screener_flag = type == :screeners + + from(ul in UserList, + where: + ul.is_deleted == false and ul.is_screener == ^screener_flag and ul.user_id == ^user_id, + preload: [:user], + order_by: [desc: ul.inserted_at] + ) + end + + defp pending_ids_query(:insights, user_id) do + from(p in Post, + where: p.is_deleted == false and p.user_id == ^user_id and is_nil(p.ai_description), + order_by: [desc: p.inserted_at], + select: p.id + ) + end + + defp pending_ids_query(:charts, user_id) do + from(c in Configuration, + where: c.is_deleted == false and c.user_id == ^user_id and is_nil(c.ai_description), + order_by: [desc: c.inserted_at], + select: c.id + ) + end + + defp pending_ids_query(type, user_id) when type in [:screeners, :watchlists] do + screener_flag = type == :screeners + + from(ul in UserList, + where: + ul.is_deleted == false and ul.is_screener == ^screener_flag and ul.user_id == ^user_id and + is_nil(ul.ai_description), + order_by: [desc: ul.inserted_at], + select: ul.id + ) + end + + defp count_query(:insights, user_id) do + from(p in Post, where: p.is_deleted == false and p.user_id == ^user_id) + end + + defp count_query(:charts, user_id) do + from(c in Configuration, where: c.is_deleted == false and c.user_id == ^user_id) + end + + defp count_query(type, user_id) when type in [:screeners, :watchlists] do + screener_flag = type == :screeners + + from(ul in UserList, + where: + ul.is_deleted == false and ul.is_screener == ^screener_flag and ul.user_id == ^user_id + ) + end +end diff --git a/lib/sanbase/alerts/alerts.ex b/lib/sanbase/alerts/alerts.ex new file mode 100644 index 0000000000..f95982edd6 --- /dev/null +++ b/lib/sanbase/alerts/alerts.ex @@ -0,0 +1,43 @@ +defmodule Sanbase.Alerts do + @moduledoc ~s""" + Context module for alert (UserTrigger) orchestration. Web callers should go + through this module rather than reaching into `UserTrigger` and `Telegram` + separately. + """ + + alias Sanbase.Accounts.User + alias Sanbase.Alert.{Trigger, UserTrigger} + alias Sanbase.Repo + alias Sanbase.Telegram + + @doc ~s""" + Create a UserTrigger for `user`, preload its `:tags`, and notify the user via + Telegram. The Telegram notification is best-effort and is skipped on failure. + """ + @spec create_trigger(User.t(), map()) :: + {:ok, UserTrigger.t()} | {:error, Ecto.Changeset.t() | String.t()} + def create_trigger(%User{} = user, args) do + with {:ok, %UserTrigger{} = user_trigger} <- UserTrigger.create_user_trigger(user, args) do + user_trigger = Repo.preload(user_trigger, :tags) + _ = notify_trigger_created(user, args) + {:ok, user_trigger} + end + end + + defp notify_trigger_created(%User{} = user, args) do + Telegram.send_message(user, build_trigger_created_message(args)) + end + + defp build_trigger_created_message(args) do + type = Trigger.human_readable_settings_type(args.settings["type"]) + description = if args[:description], do: "\nDescription: #{args[:description]}" + + """ + Successfully created a new alert of type: #{type} + + Title: #{args.title}#{description} + + This bot will send you a message when the alert triggers πŸ€– + """ + end +end diff --git a/lib/sanbase/billing/billing.ex b/lib/sanbase/billing/billing.ex index 508df83ec2..96f5b3f619 100644 --- a/lib/sanbase/billing/billing.ex +++ b/lib/sanbase/billing/billing.ex @@ -36,6 +36,49 @@ defmodule Sanbase.Billing do defdelegate create_free_basic_api, to: ProPlus defdelegate delete_free_basic_api, to: ProPlus + @doc ~s""" + Return the user's current Sanbase plan name (e.g. "FREE", "PRO", "MAX"). + Wraps `Subscription.current_subscription/2` + `Subscription.plan_name/1` so + callers do not need to import `Product` or `Subscription` directly. + """ + @spec sanbase_plan_name(User.t() | non_neg_integer()) :: String.t() + def sanbase_plan_name(user_or_id) do + user_or_id + |> Subscription.current_subscription(Product.product_sanbase()) + |> Subscription.plan_name() + end + + @doc ~s""" + Return the user's effective plan name across Sanbase and API products. + When the Sanbase plan is "FREE", fall back to the API product's plan name. + """ + @spec sanbase_or_api_plan_name(non_neg_integer()) :: String.t() + def sanbase_or_api_plan_name(user_id) when is_integer(user_id) do + case Subscription.current_subscription_plan(user_id, Product.product_sanbase()) do + "FREE" -> Subscription.current_subscription_plan(user_id, Product.product_api()) + sanbase_plan -> sanbase_plan + end + end + + @doc ~s""" + Return the user's current Sanbase subscription struct (or `nil`). + """ + @spec sanbase_subscription(non_neg_integer()) :: Subscription.t() | nil + def sanbase_subscription(user_id) when is_integer(user_id) do + Subscription.get_user_subscription(user_id, Product.product_sanbase()) + end + + @doc ~s""" + True if the user has a non-FREE plan on the given product. + """ + @spec user_has_product_access?(User.t() | non_neg_integer(), non_neg_integer()) :: boolean() + def user_has_product_access?(user_or_id, product_id) do + case Subscription.current_subscription(user_or_id, product_id) do + nil -> false + %Subscription{} = subscription -> Subscription.plan_name(subscription) != "FREE" + end + end + def list_products(), do: Repo.all(Product) def list_plans() do @@ -124,6 +167,189 @@ defmodule Sanbase.Billing do end end + # ────────────────────────────────────────────────────────────────── + # Stripe-facing operations + # + # The web/resolver layer should call these wrappers rather than + # `Sanbase.StripeApi` directly. Each wrapper hides Stripe.* structs + # from callers and returns plain maps or already-mapped errors. + # ────────────────────────────────────────────────────────────────── + + @doc ~s""" + Fetch the latest Stripe state for the user's subscription and sync the local + record (used when the UI needs the freshest payment-intent client secret). + Returns the same `{:ok, subscription}` shape `Subscription.by_id/1` does, or + one of the tagged-tuple error shapes consumed by the resolver's error + handler. + """ + @spec refresh_subscription_payment_intent(User.t(), non_neg_integer()) :: + {:ok, Subscription.t()} | {:subscription?, any()} | {:error, any()} + def refresh_subscription_payment_intent(%User{id: user_id}, subscription_id) do + with {_, %Subscription{user_id: ^user_id} = subscription} <- + {:subscription?, Subscription.by_id(subscription_id)}, + {:ok, stripe_subscription} <- StripeApi.retrieve_subscription(subscription.stripe_id) do + Subscription.sync_subscription_with_stripe(stripe_subscription, subscription) + end + end + + @doc "List the user's past Stripe charges, mapped to GraphQL-shaped maps." + @spec list_payments(User.t()) :: {:ok, [map()]} | {:error, any()} + def list_payments(%User{} = user) do + case StripeApi.list_payments(user) do + {:ok, []} -> {:ok, []} + {:ok, %Stripe.List{data: payments}} -> {:ok, transform_payments(payments)} + {:error, reason} -> {:error, reason} + end + end + + @doc "Retrieve a Stripe coupon and project it onto a GraphQL-shaped map." + @spec retrieve_coupon(String.t()) :: {:ok, map()} | {:error, any()} + def retrieve_coupon(coupon) do + case StripeApi.retrieve_coupon(coupon) do + {:ok, + %Stripe.Coupon{ + valid: valid, + id: id, + name: name, + percent_off: percent_off, + amount_off: amount_off + }} -> + {:ok, + %{ + is_valid: valid, + id: id, + name: name, + percent_off: percent_off, + amount_off: amount_off + }} + + {:error, reason} -> + {:error, reason} + end + end + + @doc ~s""" + Upcoming invoice for the user's subscription. Returns a `{period_start, + period_end, amount_due}` map, `{:error, message}`, or `:no_subscription` / + `:not_billable` for invariant failures. + """ + @spec upcoming_invoice(User.t(), non_neg_integer()) :: {:ok, map()} | {:error, any()} | atom() + def upcoming_invoice(%User{id: user_id}, subscription_id) do + with %Subscription{user_id: ^user_id} = subscription <- Subscription.by_id(subscription_id), + true <- subscription.status in [:active, :trialing, :past_due], + {:ok, %Stripe.Invoice{} = invoice} <- StripeApi.upcoming_invoice(subscription.stripe_id) do + {:ok, + %{ + period_start: DateTime.from_unix!(invoice.period_start), + period_end: DateTime.from_unix!(invoice.period_end), + amount_due: invoice.total + }} + end + end + + @doc "Default payment instrument projected to a GraphQL-shaped map." + @spec default_payment_instrument(User.t()) :: {:ok, map()} | {:card?, nil} | {:error, any()} + def default_payment_instrument(%User{} = user) do + with {:ok, customer} <- StripeApi.fetch_stripe_customer(user), + {:card?, card} when not is_nil(card) <- {:card?, choose_default_card(customer)} do + {:ok, + %{ + last4: card.last4, + dynamic_last4: card[:dynamic_last4], + exp_year: card.exp_year, + exp_month: card.exp_month, + brand: card.brand, + funding: card.funding + }} + end + end + + @doc "Replace the user's default payment card with `card_token`." + @spec update_default_payment_instrument(User.t(), String.t()) :: + {:ok, true} | {:error, any()} + def update_default_payment_instrument(%User{} = user, card_token) do + if user.stripe_customer_id do + StripeApi.maybe_detach_payment_method(user.stripe_customer_id) + end + + case create_or_update_stripe_customer(user, card_token) do + {:ok, _} -> {:ok, true} + {:error, reason} -> {:error, reason} + end + end + + @doc "Detach the user's default payment card from their Stripe customer." + @spec delete_default_payment_instrument(User.t()) :: {:ok, true} | :error | {:error, any()} + def delete_default_payment_instrument(%User{} = user) do + case StripeApi.delete_default_card(user) do + :ok -> {:ok, true} + other -> other + end + end + + @doc "Create a Stripe SetupIntent and return its `client_secret`." + @spec create_setup_intent(User.t()) :: {:ok, %{client_secret: String.t()}} | {:error, any()} + def create_setup_intent(%User{} = user) do + case StripeApi.create_setup_intent(user) do + {:ok, setup_intent} -> {:ok, %{client_secret: setup_intent.client_secret}} + {:error, reason} -> {:error, reason} + end + end + + @doc "Stripe customer balance (in SAN credits), expressed as a positive float." + @spec san_credit_balance(User.t()) :: float() + def san_credit_balance(%User{} = user) do + with {:ok, customer} <- StripeApi.retrieve_customer(user), + true <- customer.balance < 0 do + -(customer.balance / 100) + else + _ -> 0.00 + end + end + + defp transform_payments(payments) do + Enum.map(payments, fn + %Stripe.Charge{ + status: status, + amount: amount, + created: created, + receipt_url: receipt_url, + description: description + } -> + %{ + status: status, + amount: amount, + created_at: DateTime.from_unix!(created), + receipt_url: receipt_url, + description: description + } + end) + end + + # default card can be either a card token or a payment method + # they are stored in different places in the customer object + defp choose_default_card(customer) do + cond do + # Check for default payment method first + is_map(customer.invoice_settings) and customer.invoice_settings.default_payment_method -> + pm_id = customer.invoice_settings.default_payment_method + {:ok, pm} = StripeApi.retrieve_payment_method(pm_id) + pm.card + + # Fall back to default source if it exists and is a card + customer.default_source && is_struct(customer.default_source, Stripe.Card) -> + Map.from_struct(customer.default_source) + + # Handle card source type + customer.default_source && is_map(customer.default_source) && + Map.get(customer.default_source, :type) == "card" -> + get_in(customer.default_source, [:card]) || customer.default_source + + true -> + nil + end + end + def get_sanbase_pro_user_ids() do sanbase_user_ids_mapset = Subscription.get_direct_sanbase_pro_user_ids() diff --git a/lib/sanbase/changelog/changelog.ex b/lib/sanbase/changelog/changelog.ex new file mode 100644 index 0000000000..ed203ce306 --- /dev/null +++ b/lib/sanbase/changelog/changelog.ex @@ -0,0 +1,33 @@ +defmodule Sanbase.Changelog do + @moduledoc ~s""" + Unified read-side faΓ§ade for the metric and asset changelogs. Web/transport + callers should ask this module for changelog pages rather than reaching into + `Sanbase.Metric.Registry.MetricVersions` and `Sanbase.Project.ProjectVersions` + separately. + """ + + alias Sanbase.Metric.Registry.MetricVersions + alias Sanbase.Project.ProjectVersions + + @doc ~s""" + Page of metric changelog entries grouped by date. Returns + `{entries, has_more, total_dates}` exactly as + `MetricVersions.get_changelog_by_date/3` does. + """ + @spec metrics_changelog(non_neg_integer(), non_neg_integer(), String.t() | nil) :: + {list(), boolean(), non_neg_integer()} + def metrics_changelog(limit, offset, search_term \\ nil) do + MetricVersions.get_changelog_by_date(limit, offset, search_term) + end + + @doc ~s""" + Page of asset changelog entries grouped by date. Returns + `{entries, total_dates}` exactly as + `ProjectVersions.get_changelog_by_date/3` does. + """ + @spec assets_changelog(non_neg_integer(), non_neg_integer(), String.t() | nil) :: + {list(), non_neg_integer()} + def assets_changelog(page, page_size, search_term \\ nil) do + ProjectVersions.get_changelog_by_date(page, page_size, search_term) + end +end diff --git a/lib/sanbase/insights/insights.ex b/lib/sanbase/insights/insights.ex new file mode 100644 index 0000000000..13c4dbba11 --- /dev/null +++ b/lib/sanbase/insights/insights.ex @@ -0,0 +1,132 @@ +defmodule Sanbase.Insights do + @moduledoc ~s""" + Public faΓ§ade for the insights domain. The web/transport layer should call + only this module rather than reaching into `Sanbase.Insight.Post` and the + surrounding helpers directly. + """ + + alias Sanbase.Accounts.User + alias Sanbase.Insight.{Post, PostImage, ImageUrl, PopularAuthor, Category} + + @empty_count_map %{total_count: 0, draft_count: 0, pulse_count: 0, paywall_count: 0} + + @doc "Default zeroed insights-count map used when dataloader has no entry." + @spec empty_insights_count() :: map() + def empty_insights_count, do: @empty_count_map + + @doc "Top insight authors with their counts." + defdelegate popular_authors(), to: PopularAuthor, as: :get + + defdelegate user_insights(user_id, opts), to: Post + defdelegate user_public_insights(user_id, opts), to: Post + defdelegate public_insights(opts), to: Post + defdelegate public_insights_by_tags(tags, opts), to: Post + defdelegate search_published(search_term, opts), to: Post, as: :search_published_insights + defdelegate user_voted_insights(user_id, opts), to: Post, as: :all_insights_user_voted_for + + defdelegate related_projects(post), to: Post + defdelegate pulse?(post), to: Post + + @doc "Pulse insights expose their text via this field; non-pulse insights get nil." + @spec pulse_text(Post.t()) :: {:ok, String.t() | nil} + def pulse_text(%Post{} = post) do + if Post.pulse?(post), do: {:ok, post.text}, else: {:ok, nil} + end + + @doc ~s""" + Fetch an insight visible to `viewer_user_id`. Approved-and-published posts + are visible to everyone; owners always see their own drafts/unpublished + posts. Returns the same shape `Post.by_id/2` would. + """ + @spec get_post(non_neg_integer(), non_neg_integer() | nil) :: + {:ok, Post.t()} | {:error, String.t() | any()} + def get_post(post_id, viewer_user_id) do + case Post.by_id(post_id, []) do + {:ok, %Post{state: "approved", ready_state: "published"} = post} -> + {:ok, post} + + {:ok, %Post{user_id: ^viewer_user_id} = post} when not is_nil(viewer_user_id) -> + {:ok, post} + + {:ok, _} -> + {:error, + "Insight with id #{post_id} does not exist, is not published, or is not approved"} + + {:error, reason} -> + {:error, reason} + end + end + + @doc ~s""" + Create a post if the author has not exceeded their daily rate limit. Returns + `{:error, message}` if the limit is hit, otherwise forwards to `Post.create/2`. + """ + @spec create_post(User.t(), map()) :: {:ok, Post.t()} | {:error, any()} + def create_post(%User{} = user, args) do + case Post.has_not_reached_rate_limits?(user.id) do + {:ok, _} -> Post.create(user, args) + {:error, error} -> {:error, error} + end + end + + defdelegate update_post(post_id, user, args), to: Post, as: :update + defdelegate delete_post(post_id, user), to: Post, as: :delete + defdelegate publish(post_id, user_id), to: Post + defdelegate create_chart_event(user_id, args), to: Post + + @doc "All tags used across published insights." + def all_tags, do: Sanbase.Tag.all() + + @doc "All insight categories with the count of published insights in each." + defdelegate all_categories_with_count(), to: Category, as: :all_with_insight_count + + @doc ~s""" + Resolve the full ordered list of images for an insight: regex-extract image + URLs from the post body (preserving in-text order and preferring those URLs), + enriched with thumbnail variants from `PostImage` rows when available, and + followed by any DB-only images that no longer appear in the text. + """ + @spec resolve_post_images(Post.t()) :: {:ok, [map()]} + def resolve_post_images(%Post{text: text, images: images}) do + db_images = + case images do + list when is_list(list) -> Enum.map(list, &post_image_to_map/1) + _ -> [] + end + + db_image_by_url = Map.new(db_images, fn img -> {String.downcase(img.image_url), img} end) + + regex_images = + text + |> ImageUrl.extract_from_text() + |> Enum.map(fn url -> + case Map.get(db_image_by_url, String.downcase(url)) do + nil -> %{image_url: url} + db_img -> %{db_img | image_url: url} + end + end) + + text_urls = MapSet.new(regex_images, fn %{image_url: url} -> String.downcase(url) end) + + orphan_db_images = + Enum.reject(db_images, fn %{image_url: url} -> + MapSet.member?(text_urls, String.downcase(url)) + end) + + all_images = + (regex_images ++ orphan_db_images) + |> Enum.uniq_by(fn %{image_url: url} -> String.downcase(url) end) + + {:ok, all_images} + end + + defp post_image_to_map(%PostImage{} = image) do + %{ + image_url: image.image_url, + image_url_w400: image.image_url_w400, + image_url_w800: image.image_url_w800, + image_url_w1200: image.image_url_w1200, + image_url_w2000: image.image_url_w2000 + } + end +end diff --git a/lib/sanbase/metric/metric_registry_facade.ex b/lib/sanbase/metric/metric_registry_facade.ex new file mode 100644 index 0000000000..01446af0e9 --- /dev/null +++ b/lib/sanbase/metric/metric_registry_facade.ex @@ -0,0 +1,84 @@ +defmodule Sanbase.MetricRegistry do + @moduledoc ~s""" + Public faΓ§ade for the metric registry domain. + + The metric registry is split across many submodules β€” `Sanbase.Metric.Registry` + holds the canonical metric definitions; `Sanbase.Metric.Registry.Changelog`, + `.MetricVersions`, `.ChangeSuggestion`, `.Sync` cover historical/diff/sync + views; `Sanbase.Metric.Category` and `Sanbase.Metric.UIMetadata.*` provide the + human-facing categorization and display ordering. Web/LiveView callers should + use this module rather than reaching into the internals directly, so the + interaction surface stays small as the schemas evolve. + + This module is a thin shim around those submodules. It does not own state of + its own; the underlying modules remain the source of truth and are still the + right place for behavior changes. + """ + + alias Sanbase.Metric.Registry + alias Sanbase.Metric.Registry.{Changelog, ChangeSuggestion, MetricVersions, Sync} + alias Sanbase.Metric.Category + alias Sanbase.Metric.UIMetadata + + # ── Registry CRUD/lookup ────────────────────────────────────────────── + defdelegate all(), to: Registry + defdelegate by_id(id), to: Registry + defdelegate by_ids(ids), to: Registry + defdelegate aggregations(), to: Registry + defdelegate allowed_statuses(), to: Registry + defdelegate resolve(list), to: Registry + defdelegate resolve_safe(list), to: Registry + defdelegate update_is_verified(registry, is_verified), to: Registry + + # ── Changelog / versions / suggestions / sync ───────────────────────── + defdelegate changelog_by_metric_registry_id(id), to: Changelog, as: :by_metric_registry_id + + defdelegate changelog_state_before_last_sync(metric_registry_id, last_sync_datetime), + to: Changelog, + as: :state_before_last_sync + + defdelegate metric_registry_ids_with_changes(), to: Changelog + + defdelegate metric_versions_changelog(limit, offset, search_term \\ nil), + to: MetricVersions, + as: :get_changelog_by_date + + defdelegate change_suggestion_update_status(id, new_status), + to: ChangeSuggestion, + as: :update_status + + defdelegate sync_apply(params), to: Sync, as: :apply_sync + defdelegate sync_by_uuid(uuid, sync_type), to: Sync, as: :by_uuid + defdelegate sync_cancel_run(uuid, sync_type), to: Sync, as: :cancel_run + defdelegate sync_last_runs(limit), to: Sync, as: :last_syncs + + defdelegate sync_mark_completed(sync_uuid, actual_changes), + to: Sync, + as: :mark_sync_as_completed + + defdelegate sync_run(metric_registry_ids, opts \\ []), to: Sync, as: :sync + + # ── Categorization (DB-backed Metric.Category) ──────────────────────── + defdelegate category_ordered_metrics(), to: Category, as: :get_ordered_metrics + + defdelegate category_mappings_by_metric_registry_id(id), + to: Category, + as: :get_mappings_by_metric_registry_id + + # ── UI metadata categories and groups ───────────────────────────────── + defdelegate ui_category_by_name(name), to: UIMetadata.Category, as: :by_name + + defdelegate ui_group_by_name_and_category(name, category_id), + to: UIMetadata.Group, + as: :by_name_and_category + + defdelegate ui_groups_by_category(category_id), to: UIMetadata.Group, as: :by_category + defdelegate ui_group_delete(group), to: UIMetadata.Group, as: :delete + + defdelegate ui_display_order_ordered_metrics(), + to: UIMetadata.DisplayOrder, + as: :get_ordered_metrics + + # ── Helper (registered metric modules) ──────────────────────────────── + defdelegate metric_modules(), to: Sanbase.Metric.Helper +end diff --git a/lib/sanbase/reports/report.ex b/lib/sanbase/reports/report.ex index 06f49a8c29..59a86eec21 100644 --- a/lib/sanbase/reports/report.ex +++ b/lib/sanbase/reports/report.ex @@ -30,8 +30,11 @@ defmodule Sanbase.Report do timestamps() end - @doc false - def new_changeset(report, attrs \\ %{}) do + @doc ~s""" + Build an unvalidated changeset suitable for rendering new/edit forms. Use + `create/1` and `update/2` when persisting β€” they run the full validation set. + """ + def change_report(report \\ %__MODULE__{}, attrs \\ %{}) do attrs = normalize_tags(attrs) report diff --git a/lib/sanbase/transfers/transfers.ex b/lib/sanbase/transfers/transfers.ex index 63eb8f60b4..0a32dbc64a 100644 --- a/lib/sanbase/transfers/transfers.ex +++ b/lib/sanbase/transfers/transfers.ex @@ -2,6 +2,27 @@ defmodule Sanbase.Transfers do alias Sanbase.Project alias Sanbase.Transfers.{EthTransfers, Erc20Transfers, BtcTransfers} + alias Sanbase.Utils.BlockchainAddressUtils + alias Sanbase.Clickhouse.Label + + @doc ~s""" + Apply the standard transfer-enrichment pipeline: + `transform_address_to_map` β†’ `Label.add_labels` β†’ `MarkExchanges.mark_exchanges`. + + Pass the blockchain or slug used for label lookups; pass the infrastructure + (e.g. `"ETH"`, `"BTC"`) for address normalization, or `nil` when callers do + not have it handy. + """ + @spec enrich_with_labels(list(), String.t(), String.t() | nil) :: + {:ok, list()} | {:error, any()} + def enrich_with_labels(transfers, blockchain_or_slug, infrastructure \\ nil) do + with {:ok, transfers} <- + BlockchainAddressUtils.transform_address_to_map(transfers, infrastructure), + {:ok, transfers} <- Label.add_labels(blockchain_or_slug, transfers), + {:ok, transfers} <- Sanbase.MarkExchanges.mark_exchanges(transfers) do + {:ok, transfers} + end + end def incoming_transfers_summary(slug, address, from, to, opts) do case Project.contract_info_infrastructure_by_slug(slug) do diff --git a/lib/sanbase/voting/vote.ex b/lib/sanbase/voting/vote.ex index 0346c41b0c..3ac9d3aeb7 100644 --- a/lib/sanbase/voting/vote.ex +++ b/lib/sanbase/voting/vote.ex @@ -193,6 +193,67 @@ defmodule Sanbase.Vote do Repo.get_by(__MODULE__, opts) end + @doc ~s""" + Return `{entity_id, selector_key, votes_dataloader_query, voted_at_dataloader_query}` + for any votable entity: a votable struct, the raw `%{trigger: %{id: _}}` shape + used by Absinthe parents, or a `source`-style map carrying one of the votable + id fields. + + Returns `nil` when the input is not a votable entity. + """ + @spec dataloader_keys(any()) :: + {non_neg_integer(), atom(), atom(), atom()} | nil + def dataloader_keys(%Post{id: id}), + do: {id, :post_id, :insight_vote_stats, :insight_voted_at} + + def dataloader_keys(%UserList{id: id}), + do: {id, :watchlist_id, :watchlist_vote_stats, :watchlist_voted_at} + + def dataloader_keys(%Chart.Configuration{id: id}), + do: + {id, :chart_configuration_id, :chart_configuration_vote_stats, + :chart_configuration_voted_at} + + def dataloader_keys(%Sanbase.Dashboards.Dashboard{id: id}), + do: {id, :dashboard_id, :dashboard_vote_stats, :dashboard_voted_at} + + def dataloader_keys(%Sanbase.Queries.Query{id: id}), + do: {id, :query_id, :query_vote_stats, :query_voted_at} + + def dataloader_keys(%UserTrigger{id: id}), + do: {id, :user_trigger_id, :user_trigger_vote_stats, :user_trigger_voted_at} + + def dataloader_keys(%TimelineEvent{id: id}), + do: {id, :timeline_event_id, :timeline_event_vote_stats, :timeline_event_voted_at} + + def dataloader_keys(%{trigger: %{id: id}}) when is_integer(id), + do: {id, :user_trigger_id, :user_trigger_vote_stats, :user_trigger_voted_at} + + def dataloader_keys(%{post_id: id}) when is_integer(id), + do: {id, :post_id, :insight_vote_stats, :insight_voted_at} + + def dataloader_keys(%{watchlist_id: id}) when is_integer(id), + do: {id, :watchlist_id, :watchlist_vote_stats, :watchlist_voted_at} + + def dataloader_keys(%{chart_configuration_id: id}) when is_integer(id), + do: + {id, :chart_configuration_id, :chart_configuration_vote_stats, + :chart_configuration_voted_at} + + def dataloader_keys(%{dashboard_id: id}) when is_integer(id), + do: {id, :dashboard_id, :dashboard_vote_stats, :dashboard_voted_at} + + def dataloader_keys(%{query_id: id}) when is_integer(id), + do: {id, :query_id, :query_vote_stats, :query_voted_at} + + def dataloader_keys(%{user_trigger_id: id}) when is_integer(id), + do: {id, :user_trigger_id, :user_trigger_vote_stats, :user_trigger_voted_at} + + def dataloader_keys(%{timeline_event_id: id}) when is_integer(id), + do: {id, :timeline_event_id, :timeline_event_vote_stats, :timeline_event_voted_at} + + def dataloader_keys(_), do: nil + def user_total_votes(user_id) do query = from( diff --git a/lib/sanbase/webinars/registration.ex b/lib/sanbase/webinars/registration.ex index d699cafb90..7715e6e7af 100644 --- a/lib/sanbase/webinars/registration.ex +++ b/lib/sanbase/webinars/registration.ex @@ -7,7 +7,7 @@ defmodule Sanbase.Webinars.Registration do import Ecto.Query alias Sanbase.Accounts.User - alias Sanbase.Webinar + alias Sanbase.Webinars.Webinar alias Sanbase.Repo schema "webinar_registrations" do diff --git a/lib/sanbase/webinars/webinar.ex b/lib/sanbase/webinars/webinar.ex index e410c44af6..f09a2da3ab 100644 --- a/lib/sanbase/webinars/webinar.ex +++ b/lib/sanbase/webinars/webinar.ex @@ -1,4 +1,4 @@ -defmodule Sanbase.Webinar do +defmodule Sanbase.Webinars.Webinar do use Ecto.Schema import Ecto.Changeset import Ecto.Query diff --git a/lib/sanbase_web/controllers/report_controller.ex b/lib/sanbase_web/controllers/report_controller.ex index 03f727b7ce..f6104ff1b3 100644 --- a/lib/sanbase_web/controllers/report_controller.ex +++ b/lib/sanbase_web/controllers/report_controller.ex @@ -9,7 +9,7 @@ defmodule SanbaseWeb.ReportController do end def new(conn, _params) do - changeset = Report.new_changeset(%Report{}) + changeset = Report.change_report() render(conn, "new.html", form: Phoenix.Component.to_form(changeset)) end @@ -53,7 +53,7 @@ defmodule SanbaseWeb.ReportController do def create(conn, %{"report" => params}) do changeset = - Report.changeset(%Report{}, params) + Report.change_report(%Report{}, params) |> Ecto.Changeset.add_error(:report, "No file uploaded!") render(conn, "new.html", @@ -69,7 +69,7 @@ defmodule SanbaseWeb.ReportController do def edit(conn, %{"id" => id}) do report = Report.by_id(id) |> stringify_tags() - changeset = Report.changeset(report, %{}) + changeset = Report.change_report(report) render(conn, "edit.html", report: report, form: Phoenix.Component.to_form(changeset)) end diff --git a/lib/sanbase_web/generic_admin/webinar.ex b/lib/sanbase_web/generic_admin/webinar.ex index 7775ec8ff5..e4feea256d 100644 --- a/lib/sanbase_web/generic_admin/webinar.ex +++ b/lib/sanbase_web/generic_admin/webinar.ex @@ -1,6 +1,6 @@ defmodule SanbaseWeb.GenericAdmin.Webinar do @behaviour SanbaseWeb.GenericAdmin - def schema_module, do: Sanbase.Webinar + def schema_module, do: Sanbase.Webinars.Webinar def resource_name, do: "webinars" def singular_resource_name, do: "webinar" diff --git a/lib/sanbase_web/graphql/resolvers/billing_resolver.ex b/lib/sanbase_web/graphql/resolvers/billing_resolver.ex index 3f631216a2..b32260718f 100644 --- a/lib/sanbase_web/graphql/resolvers/billing_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/billing_resolver.ex @@ -6,8 +6,6 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do alias Sanbase.Accounts.User - alias Sanbase.StripeApi - require Logger def products_with_plans(_root, _args, _resolution) do @@ -87,18 +85,15 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do def get_subscription_with_payment_intent(_root, %{subscription_id: subscription_id}, %{ context: %{auth: %{current_user: current_user}} }) do - user_id = current_user.id + case Billing.refresh_subscription_payment_intent(current_user, subscription_id) do + {:ok, _} = ok -> + ok - with {_, %Subscription{user_id: ^user_id} = subscription} <- - {:subscription?, Subscription.by_id(subscription_id)}, - {:ok, stripe_subscription} <- StripeApi.retrieve_subscription(subscription.stripe_id) do - Subscription.sync_subscription_with_stripe(stripe_subscription, subscription) - else result -> handle_subscription_error_result( result, "Fetching latest payment intent failed", - %{user_id: user_id, subscription_id: subscription_id} + %{user_id: current_user.id, subscription_id: subscription_id} ) end end @@ -148,13 +143,9 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do def payments(_root, _args, %{ context: %{auth: %{current_user: current_user}} }) do - StripeApi.list_payments(current_user) - |> case do - {:ok, []} -> - {:ok, []} - + case Billing.list_payments(current_user) do {:ok, payments} -> - {:ok, transform_payments(payments)} + {:ok, payments} {:error, reason} -> log_error("Listing payments failed", reason) @@ -169,22 +160,8 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do with :ok <- Sanbase.Accounts.CouponAttempt.check_attempt_limit(current_user, remote_ip), {:ok, _} <- Sanbase.Accounts.CouponAttempt.create(current_user, remote_ip), - {:ok, - %Stripe.Coupon{ - valid: valid, - id: id, - name: name, - percent_off: percent_off, - amount_off: amount_off - }} <- Sanbase.StripeApi.retrieve_coupon(coupon) do - {:ok, - %{ - is_valid: valid, - id: id, - name: name, - percent_off: percent_off, - amount_off: amount_off - }} + {:ok, coupon_data} <- Billing.retrieve_coupon(coupon) do + {:ok, coupon_data} else {:error, :too_many_attempts} -> {:error, "Too many coupon attempts. Please try again later."} @@ -198,19 +175,10 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do def upcoming_invoice(_root, %{subscription_id: subscription_id}, %{ context: %{auth: %{current_user: current_user}} }) do - current_user_id = current_user.id - - with %Subscription{user_id: ^current_user_id} = subscription <- - Subscription.by_id(subscription_id), - true <- subscription.status in [:active, :trialing, :past_due], - {:ok, %Stripe.Invoice{} = invoice} <- StripeApi.upcoming_invoice(subscription.stripe_id) do - {:ok, - %{ - period_start: DateTime.from_unix!(invoice.period_start), - period_end: DateTime.from_unix!(invoice.period_end), - amount_due: invoice.total - }} - else + case Billing.upcoming_invoice(current_user, subscription_id) do + {:ok, invoice} -> + {:ok, invoice} + {:error, %Stripe.Error{message: message} = reason} -> log_error("Error fetching upcoming invoice", reason) {:error, message} @@ -223,19 +191,10 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do def fetch_default_payment_instrument(_root, _args, %{ context: %{auth: %{current_user: current_user}} }) do - with {:ok, customer} <- StripeApi.fetch_stripe_customer(current_user), - {:card?, card} when not is_nil(card) <- {:card?, choose_default_card(customer)} do - {:ok, - %{ - last4: card.last4, - # dynamic_last4 might not be present - dynamic_last4: card[:dynamic_last4], - exp_year: card.exp_year, - exp_month: card.exp_month, - brand: card.brand, - funding: card.funding - }} - else + case Billing.default_payment_instrument(current_user) do + {:ok, card} -> + {:ok, card} + {:card?, nil} -> {:error, "Customer has no default payment instrument"} @@ -303,42 +262,11 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do end end - # Private functions - - # default card can be either a card token or a payment method - # they are stored in different places in the customer object - defp choose_default_card(customer) do - cond do - # Check for default payment method first - is_map(customer.invoice_settings) and customer.invoice_settings.default_payment_method -> - pm_id = customer.invoice_settings.default_payment_method - {:ok, pm} = StripeApi.retrieve_payment_method(pm_id) - pm.card - - # Fall back to default source if it exists and is a card - customer.default_source && is_struct(customer.default_source, Stripe.Card) -> - Map.from_struct(customer.default_source) - - # Handle card source type - customer.default_source && is_map(customer.default_source) && - Map.get(customer.default_source, :type) == "card" -> - get_in(customer.default_source, [:card]) || customer.default_source - - true -> - nil - end - end - def update_default_payment_instrument(_root, %{card_token: card_token}, %{ context: %{auth: %{current_user: current_user}} }) do - if current_user.stripe_customer_id do - StripeApi.maybe_detach_payment_method(current_user.stripe_customer_id) - end - - Billing.create_or_update_stripe_customer(current_user, card_token) - |> case do - {:ok, _} -> + case Billing.update_default_payment_instrument(current_user, card_token) do + {:ok, true} -> {:ok, true} {:error, %Stripe.Error{message: message} = reason} -> @@ -353,8 +281,8 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do def delete_default_payment_instrument(_root, _args, %{ context: %{auth: %{current_user: current_user}} }) do - case StripeApi.delete_default_card(current_user) do - :ok -> + case Billing.delete_default_payment_instrument(current_user) do + {:ok, true} -> {:ok, true} {:error, %Stripe.Error{message: message} = reason} -> @@ -369,9 +297,9 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do def create_stripe_setup_intent(_root, _args, %{ context: %{auth: %{current_user: current_user}} }) do - case StripeApi.create_setup_intent(current_user) do - {:ok, setup_intent} -> - {:ok, %{client_secret: setup_intent.client_secret}} + case Billing.create_setup_intent(current_user) do + {:ok, payload} -> + {:ok, payload} {:error, %Stripe.Error{message: message} = reason} -> log_error("Create setup intent: user=#{inspect(current_user)}", reason) @@ -409,12 +337,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do end def san_credit_balance(%User{} = user, _args, _resolution) do - with {:ok, customer} <- Sanbase.StripeApi.retrieve_customer(user), - true <- customer.balance < 0 do - {:ok, -(customer.balance / 100)} - else - _ -> {:ok, 0.00} - end + {:ok, Billing.san_credit_balance(user)} end def check_annual_discount_eligibility(_root, _args, %{ @@ -424,26 +347,6 @@ defmodule SanbaseWeb.Graphql.Resolvers.BillingResolver do end # private functions - defp transform_payments(%Stripe.List{data: payments}) do - payments - |> Enum.map(fn - %Stripe.Charge{ - status: status, - amount: amount, - created: created, - receipt_url: receipt_url, - description: description - } -> - %{ - status: status, - amount: amount, - created_at: DateTime.from_unix!(created), - receipt_url: receipt_url, - description: description - } - end) - end - defp handle_subscription_error_result(result, log_message, params) do case result do {:error, %Stripe.Error{message: message} = reason} -> diff --git a/lib/sanbase_web/graphql/resolvers/blockchain_address_resolver.ex b/lib/sanbase_web/graphql/resolvers/blockchain_address_resolver.ex index a385031c5c..1b81633900 100644 --- a/lib/sanbase_web/graphql/resolvers/blockchain_address_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/blockchain_address_resolver.ex @@ -13,7 +13,6 @@ defmodule SanbaseWeb.Graphql.Resolvers.BlockchainAddressResolver do alias Sanbase.BlockchainAddress alias Sanbase.BlockchainAddress.{BlockchainAddressUserPair, BlockchainAddressLabelChange} - alias Sanbase.Utils.BlockchainAddressUtils alias SanbaseWeb.Graphql.SanbaseDataloader alias Sanbase.Clickhouse.Label alias Sanbase.Project @@ -41,9 +40,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.BlockchainAddressResolver do Transfers.top_wallet_transfers(slug, address, from, to, page, page_size, type), {:ok, transfers} <- apply_in_page_order_by(transfers, args), {:ok, _, _, infr} <- Project.contract_info_infrastructure_by_slug(slug), - {:ok, transfers} <- BlockchainAddressUtils.transform_address_to_map(transfers, infr), - {:ok, transfers} <- Label.add_labels(slug, transfers), - {:ok, transfers} <- Sanbase.MarkExchanges.mark_exchanges(transfers) do + {:ok, transfers} <- Transfers.enrich_with_labels(transfers, slug, infr) do {:ok, transfers} end end @@ -58,9 +55,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.BlockchainAddressResolver do with {:ok, transfers} <- Transfers.top_transfers(slug, from, to, page, page_size), {:ok, transfers} <- apply_in_page_order_by(transfers, args), {:ok, _, _, infr} <- Project.contract_info_infrastructure_by_slug(slug), - {:ok, transfers} <- BlockchainAddressUtils.transform_address_to_map(transfers, infr), - {:ok, transfers} <- Label.add_labels(slug, transfers), - {:ok, transfers} <- Sanbase.MarkExchanges.mark_exchanges(transfers) do + {:ok, transfers} <- Transfers.enrich_with_labels(transfers, slug, infr) do {:ok, transfers} end end @@ -93,9 +88,8 @@ defmodule SanbaseWeb.Graphql.Resolvers.BlockchainAddressResolver do with %{address: address, infrastructure: infr} <- selector_to_address_map_do_not_create(selector), {:ok, changes} <- BlockchainAddressLabelChange.label_changes(address, infr, from, to), - {:ok, changes} <- BlockchainAddressUtils.transform_address_to_map(changes, infr), - {:ok, changes} <- Label.add_labels(infrastructure_to_blockchain(infr), changes), - {:ok, changes} <- Sanbase.MarkExchanges.mark_exchanges(changes) do + {:ok, changes} <- + Transfers.enrich_with_labels(changes, infrastructure_to_blockchain(infr), infr) do {:ok, changes} end end @@ -171,10 +165,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.BlockchainAddressResolver do opts = [page: page, page_size: page_size, only_sender: only_sender] with {:ok, transfers} <- module.recent_transactions(address, opts), - {:ok, transfers} <- - BlockchainAddressUtils.transform_address_to_map(transfers, @eth_infr), - {:ok, transfers} <- Label.add_labels("ethereum", transfers), - {:ok, transfers} <- Sanbase.MarkExchanges.mark_exchanges(transfers) do + {:ok, transfers} <- Transfers.enrich_with_labels(transfers, "ethereum", @eth_infr) do {:ok, transfers} else {:error, error} -> diff --git a/lib/sanbase_web/graphql/resolvers/changelog_resolver.ex b/lib/sanbase_web/graphql/resolvers/changelog_resolver.ex index 64243c7fea..ee665e986e 100644 --- a/lib/sanbase_web/graphql/resolvers/changelog_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/changelog_resolver.ex @@ -3,8 +3,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.ChangelogResolver do Resolvers for changelog-related GraphQL queries. """ - alias Sanbase.Metric.Registry.MetricVersions - alias Sanbase.Project.ProjectVersions + alias Sanbase.Changelog alias Sanbase.Project @default_page_size 20 @@ -23,7 +22,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.ChangelogResolver do offset = (page - 1) * page_size {changelog_entries, has_more, total_dates} = - MetricVersions.get_changelog_by_date(limit, offset, search_term) + Changelog.metrics_changelog(limit, offset, search_term) entries = format_metrics_changelog_entries(changelog_entries) total_pages = calculate_total_pages(total_dates, page_size) @@ -52,7 +51,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.ChangelogResolver do search_term = Map.get(args, :search_term) {changelog_entries, total_dates} = - ProjectVersions.get_changelog_by_date(page, page_size, search_term) + Changelog.assets_changelog(page, page_size, search_term) entries = format_assets_changelog_entries(changelog_entries) total_pages = calculate_total_pages(total_dates, page_size) diff --git a/lib/sanbase_web/graphql/resolvers/insight_resolver.ex b/lib/sanbase_web/graphql/resolvers/insight_resolver.ex index 07789fb30c..c7994f93cc 100644 --- a/lib/sanbase_web/graphql/resolvers/insight_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/insight_resolver.ex @@ -4,96 +4,36 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do alias SanbaseWeb.Graphql.SanbaseDataloader alias Sanbase.Accounts.User alias Sanbase.Insight.Post - alias Sanbase.Insight.PostImage - alias Sanbase.Insight.ImageUrl - alias Sanbase.Insight.PopularAuthor + alias Sanbase.Insights alias Sanbase.Comments.EntityComment - def popular_insight_authors(_root, _args, _resolution) do - PopularAuthor.get() - end + @list_opt_keys [:is_pulse, :is_paywall_required, :from, :to] + @list_opt_keys_with_categories [:is_pulse, :is_paywall_required, :categories, :from, :to] + + def popular_insight_authors(_root, _args, _resolution), do: Insights.popular_authors() def insights(%User{} = user, %{page: page, page_size: page_size} = args, _resolution) do - opts = [ - is_pulse: Map.get(args, :is_pulse), - is_paywall_required: Map.get(args, :is_paywall_required), - from: Map.get(args, :from), - to: Map.get(args, :to), - page: page, - page_size: page_size - ] - - {:ok, Post.user_insights(user.id, opts)} + {:ok, Insights.user_insights(user.id, list_opts(args, page, page_size))} end def public_insights(%User{} = user, %{page: page, page_size: page_size} = args, _resolution) do - opts = [ - is_pulse: Map.get(args, :is_pulse), - is_paywall_required: Map.get(args, :is_paywall_required), - from: Map.get(args, :from), - to: Map.get(args, :to), - page: page, - page_size: page_size - ] - - {:ok, Post.user_public_insights(user.id, opts)} - end - - def related_projects(%Post{} = post, _, _) do - Post.related_projects(post) + {:ok, Insights.user_public_insights(user.id, list_opts(args, page, page_size))} end - def post(_root, %{id: post_id}, %{context: context} = _resolution) do - user = get_in(context, [:auth, :current_user]) || %User{id: nil} - user_id = user.id - - case Post.by_id(post_id, []) do - {:ok, %Post{state: "approved", ready_state: "published"} = post} -> - {:ok, post} - - {:ok, %Post{user_id: ^user_id} = post} -> - {:ok, post} - - {:ok, _} -> - {:error, - "Insight with id #{post_id} does not exist, is not published, or is not approved"} + def related_projects(%Post{} = post, _, _), do: Insights.related_projects(post) - {:error, reason} -> - {:error, reason} - end + def post(_root, %{id: post_id}, %{context: context}) do + viewer_id = get_in(context, [:auth, :current_user, Access.key(:id)]) + Insights.get_post(post_id, viewer_id) end def all_insights(_root, %{tags: tags, page: page, page_size: page_size} = args, _context) when is_list(tags) do - opts = [ - is_pulse: Map.get(args, :is_pulse), - is_paywall_required: Map.get(args, :is_paywall_required), - categories: Map.get(args, :categories), - from: Map.get(args, :from), - to: Map.get(args, :to), - page: page, - page_size: page_size - ] - - posts = Post.public_insights_by_tags(tags, opts) - - {:ok, posts} + {:ok, Insights.public_insights_by_tags(tags, list_opts(args, page, page_size, :categories))} end def all_insights(_root, %{page: page, page_size: page_size} = args, _resolution) do - opts = [ - is_pulse: Map.get(args, :is_pulse), - is_paywall_required: Map.get(args, :is_paywall_required), - categories: Map.get(args, :categories), - from: Map.get(args, :from), - to: Map.get(args, :to), - page: page, - page_size: page_size - ] - - posts = Post.public_insights(opts) - - {:ok, posts} + {:ok, Insights.public_insights(list_opts(args, page, page_size, :categories))} end def all_insights_for_user( @@ -101,45 +41,15 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do %{user_id: user_id, page: page, page_size: page_size} = args, _context ) do - opts = [ - is_pulse: Map.get(args, :is_pulse), - is_paywall_required: Map.get(args, :is_paywall_required), - categories: Map.get(args, :categories), - from: Map.get(args, :from), - to: Map.get(args, :to), - page: page, - page_size: page_size - ] - - posts = Post.user_public_insights(user_id, opts) - - {:ok, posts} + {:ok, Insights.user_public_insights(user_id, list_opts(args, page, page_size, :categories))} end def all_insights_user_voted_for(_root, %{user_id: user_id} = args, _context) do - opts = [ - is_pulse: Map.get(args, :is_pulse), - is_paywall_required: Map.get(args, :is_paywall_required), - from: Map.get(args, :from), - to: Map.get(args, :to) - ] - - posts = Post.all_insights_user_voted_for(user_id, opts) - - {:ok, posts} + {:ok, Insights.user_voted_insights(user_id, list_opts(args))} end def all_insights_by_tag(_root, %{tag: tag} = args, _context) do - opts = [ - is_pulse: Map.get(args, :is_pulse), - is_paywall_required: Map.get(args, :is_paywall_required), - from: Map.get(args, :from), - to: Map.get(args, :to) - ] - - posts = Post.public_insights_by_tags([tag], opts) - - {:ok, posts} + {:ok, Insights.public_insights_by_tags([tag], list_opts(args))} end def all_insights_by_search_term( @@ -147,70 +57,33 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do %{search_term: search_term, page: page, page_size: page_size} = args, _context ) do - opts = [ - is_pulse: Map.get(args, :is_pulse), - is_paywall_required: Map.get(args, :is_paywall_required), - from: Map.get(args, :from), - to: Map.get(args, :to), - page: page, - page_size: page_size - ] - - # Search is done only on the publicly visible (published) insights. - search_result_insights = Post.search_published_insights(search_term, opts) - - {:ok, search_result_insights} + {:ok, Insights.search_published(search_term, list_opts(args, page, page_size))} end - @doc ~s""" - When fetching all insights we need to directly show only the text of the pulse insights. - In order to transport less data over the network, this field can be used instead of - the `text` field as it will be filled only for those insights. - """ - def pulse_text(%Post{} = post, _args, _resolution) do - case Post.pulse?(post) do - true -> {:ok, post.text} - _ -> {:ok, nil} - end - end + def pulse_text(%Post{} = post, _args, _resolution), do: Insights.pulse_text(post) - def create_post(_root, args, %{context: %{auth: %{current_user: user}}}) do - case Post.has_not_reached_rate_limits?(user.id) do - {:ok, _} -> Post.create(user, args) - {:error, error} -> {:error, error} - end - end + def create_post(_root, args, %{context: %{auth: %{current_user: user}}}), + do: Insights.create_post(user, args) def update_post(_root, %{id: post_id} = args, %{ context: %{auth: %{current_user: %User{} = user}} - }) do - Post.update(post_id, user, args) - end + }), + do: Insights.update_post(post_id, user, args) def delete_post(_root, %{id: post_id}, %{ context: %{auth: %{current_user: %User{} = user}} - }) do - Post.delete(post_id, user) - end + }), + do: Insights.delete_post(post_id, user) def publish_insight(_root, %{id: post_id}, %{ context: %{auth: %{current_user: %User{id: user_id}}} - }) do - Post.publish(post_id, user_id) - end + }), + do: Insights.publish(post_id, user_id) - def all_tags(_root, _args, _context) do - {:ok, Sanbase.Tag.all()} - end + def all_tags(_root, _args, _context), do: {:ok, Insights.all_tags()} - @doc "Returns all insight categories with the count of published insights in each." - @spec all_insight_categories(any(), map(), any()) :: {:ok, list(map())} - def all_insight_categories(_root, _args, _context) do - Sanbase.Insight.Category.all_with_insight_count() - end + def all_insight_categories(_root, _args, _context), do: Insights.all_categories_with_count() - @doc "Returns the categories assigned to a given post via dataloader." - @spec post_categories(%Post{}, map(), Absinthe.Resolution.t()) :: any() def post_categories(%Post{id: id}, _args, %{context: %{loader: loader}}) do loader |> Dataloader.load(SanbaseDataloader, :post_categories, id) @@ -224,68 +97,17 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do end) end - @doc """ - Resolve images for an insight by combining DB-linked images with - regex-extracted images from text. This ensures backward compatibility - for old insights where images were never properly linked in the DB. - """ - def resolve_images(%Post{text: text, images: images}, _args, _resolution) do - db_images = - case images do - images when is_list(images) -> - Enum.map(images, &post_image_to_map/1) - - _ -> - [] - end - - # Build a case-insensitive lookup from DB images to get variant URLs - db_image_by_url = - Map.new(db_images, fn img -> {String.downcase(img.image_url), img} end) - - # Regex images are in text-appearance order β€” use them as the primary source - regex_images = - ImageUrl.extract_from_text(text) - |> Enum.map(fn url -> - case Map.get(db_image_by_url, String.downcase(url)) do - nil -> %{image_url: url} - db_img -> %{db_img | image_url: url} - end - end) - - # Append any DB-only images not found in the text - text_urls = - MapSet.new(regex_images, fn %{image_url: url} -> String.downcase(url) end) - - orphan_db_images = - Enum.reject(db_images, fn %{image_url: url} -> - MapSet.member?(text_urls, String.downcase(url)) - end) - - all_images = - (regex_images ++ orphan_db_images) - |> Enum.uniq_by(fn %{image_url: url} -> String.downcase(url) end) - - {:ok, all_images} - end - - defp post_image_to_map(%PostImage{} = image) do - %{ - image_url: image.image_url, - image_url_w400: image.image_url_w400, - image_url_w800: image.image_url_w800, - image_url_w1200: image.image_url_w1200, - image_url_w2000: image.image_url_w2000 - } - end + def resolve_images(%Post{} = post, _args, _resolution), do: Insights.resolve_post_images(post) def insights_count(%User{id: id}, _args, %{context: %{loader: loader}}) do loader |> Dataloader.load(SanbaseDataloader, :insights_count_per_user, id) |> on_load(fn loader -> - {:ok, - Dataloader.get(loader, SanbaseDataloader, :insights_count_per_user, id) || - %{total_count: 0, draft_count: 0, pulse_count: 0, paywall_count: 0}} + count = + Dataloader.get(loader, SanbaseDataloader, :insights_count_per_user, id) || + Insights.empty_insights_count() + + {:ok, count} end) end @@ -306,7 +128,22 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do end) end - def create_chart_event(_root, args, %{context: %{auth: %{current_user: user}}}) do - Post.create_chart_event(user.id, args) + def create_chart_event(_root, args, %{context: %{auth: %{current_user: user}}}), + do: Insights.create_chart_event(user.id, args) + + defp list_opts(args, page, page_size, :categories) do + args + |> Map.take(@list_opt_keys_with_categories) + |> Map.to_list() + |> Keyword.merge(page: page, page_size: page_size) + end + + defp list_opts(args, page, page_size) do + args + |> Map.take(@list_opt_keys) + |> Map.to_list() + |> Keyword.merge(page: page, page_size: page_size) end + + defp list_opts(args), do: args |> Map.take(@list_opt_keys) |> Map.to_list() end diff --git a/lib/sanbase_web/graphql/resolvers/metric/metric_display_order_resolver.ex b/lib/sanbase_web/graphql/resolvers/metric/metric_display_order_resolver.ex index 042c548fe2..0f2fa4a944 100644 --- a/lib/sanbase_web/graphql/resolvers/metric/metric_display_order_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/metric/metric_display_order_resolver.ex @@ -1,4 +1,5 @@ defmodule SanbaseWeb.Graphql.Resolvers.MetricDisplayOrderResolver do + alias Sanbase.MetricRegistry alias Sanbase.Metric.UIMetadata.DisplayOrder alias Sanbase.Repo @@ -14,7 +15,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.MetricDisplayOrderResolver do end def get_ordered_metrics(_root, _args, _resolution) do - ordered_data = DisplayOrder.get_ordered_metrics() + ordered_data = MetricRegistry.ui_display_order_ordered_metrics() {:ok, %{ @@ -24,7 +25,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.MetricDisplayOrderResolver do end def get_ordered_metrics_v2(_root, _args, _resolution) do - ordered_data = Sanbase.Metric.Category.get_ordered_metrics() + ordered_data = MetricRegistry.category_ordered_metrics() # Update the dispaly_order based on category. All metrics inside the same category have # display_order in ascending order without gaps starting from 1 @@ -48,7 +49,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.MetricDisplayOrderResolver do def get_metrics_by_category(_root, %{category: category}, _resolution) do # Find category by name - case Sanbase.Metric.UIMetadata.Category.by_name(category) do + case MetricRegistry.ui_category_by_name(category) do nil -> {:ok, []} @@ -64,13 +65,13 @@ defmodule SanbaseWeb.Graphql.Resolvers.MetricDisplayOrderResolver do def get_metrics_by_category_and_group(_root, %{category: category, group: group}, _resolution) do # Find category by name - case Sanbase.Metric.UIMetadata.Category.by_name(category) do + case MetricRegistry.ui_category_by_name(category) do nil -> {:ok, []} category_record -> # Find group by name and category_id - case Sanbase.Metric.UIMetadata.Group.by_name_and_category(group, category_record.id) do + case MetricRegistry.ui_group_by_name_and_category(group, category_record.id) do nil -> {:ok, []} diff --git a/lib/sanbase_web/graphql/resolvers/project/project_transfers_resolver.ex b/lib/sanbase_web/graphql/resolvers/project/project_transfers_resolver.ex index 70dd082b1c..3a348814f0 100644 --- a/lib/sanbase_web/graphql/resolvers/project/project_transfers_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/project/project_transfers_resolver.ex @@ -6,9 +6,8 @@ defmodule SanbaseWeb.Graphql.Resolvers.ProjectTransfersResolver do alias Sanbase.Transfers alias Sanbase.Project - alias Sanbase.Utils.BlockchainAddressUtils alias SanbaseWeb.Graphql.{Cache, SanbaseDataloader} - alias Sanbase.Clickhouse.{Label, HistoricalBalance.EthSpent} + alias Sanbase.Clickhouse.HistoricalBalance.EthSpent @max_concurrency 100 @@ -26,9 +25,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.ProjectTransfersResolver do opts = [excluded_addresses: Map.get(args, :excluded_addresses, [])] with {:ok, transfers} <- Transfers.top_transfers(slug, from, to, 1, limit, opts), - {:ok, transfers} <- BlockchainAddressUtils.transform_address_to_map(transfers), - {:ok, transfers} <- Label.add_labels(slug, transfers), - {:ok, transfers} <- Sanbase.MarkExchanges.mark_exchanges(transfers) do + {:ok, transfers} <- Transfers.enrich_with_labels(transfers, slug) do {:ok, transfers} else {:error, {:missing_contract, _}} -> @@ -159,9 +156,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.ProjectTransfersResolver do limit = Enum.min([limit, 100]) with {:ok, transfers} <- Transfers.top_transfers("ethereum", from, to, 1, limit), - {:ok, transfers} <- BlockchainAddressUtils.transform_address_to_map(transfers), - {:ok, transfers} <- Label.add_labels("ethereum", transfers), - {:ok, transfers} <- Sanbase.MarkExchanges.mark_exchanges(transfers) do + {:ok, transfers} <- Transfers.enrich_with_labels(transfers, "ethereum") do {:ok, transfers} else error -> @@ -181,9 +176,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.ProjectTransfersResolver do with {:ok, addresses} <- Project.eth_addresses(project), {:ok, transfers} <- Transfers.top_wallet_transfers("ethereum", addresses, from, to, 1, limit, type), - {:ok, transfers} <- BlockchainAddressUtils.transform_address_to_map(transfers, infr), - {:ok, transfers} <- Label.add_labels("ethereum", transfers), - {:ok, transfers} <- Sanbase.MarkExchanges.mark_exchanges(transfers) do + {:ok, transfers} <- Transfers.enrich_with_labels(transfers, "ethereum", infr) do {:ok, transfers} else error -> diff --git a/lib/sanbase_web/graphql/resolvers/report_resolver.ex b/lib/sanbase_web/graphql/resolvers/report_resolver.ex index 8c4af4e5fb..2bd2c55a75 100644 --- a/lib/sanbase_web/graphql/resolvers/report_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/report_resolver.ex @@ -1,6 +1,6 @@ defmodule SanbaseWeb.Graphql.Resolvers.ReportResolver do alias Sanbase.Report - alias Sanbase.Billing.{Subscription, Product} + alias Sanbase.Billing def upload_report(_root, %{report: report} = args, _resolution) do {params, _} = Map.split(args, [:name, :description]) @@ -35,10 +35,5 @@ defmodule SanbaseWeb.Graphql.Resolvers.ReportResolver do {:ok, Report.get_by_tags(tags, %{is_logged_in: false})} end - defp get_user_plan(user_id) do - case Subscription.current_subscription_plan(user_id, Product.product_sanbase()) do - "FREE" -> Subscription.current_subscription_plan(user_id, Product.product_api()) - sanbase_plan -> sanbase_plan - end - end + defp get_user_plan(user_id), do: Billing.sanbase_or_api_plan_name(user_id) end diff --git a/lib/sanbase_web/graphql/resolvers/sheets_template_resolver.ex b/lib/sanbase_web/graphql/resolvers/sheets_template_resolver.ex index ad2fce5eee..6b63070dd9 100644 --- a/lib/sanbase_web/graphql/resolvers/sheets_template_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/sheets_template_resolver.ex @@ -1,11 +1,9 @@ defmodule SanbaseWeb.Graphql.Resolvers.SheetsTemplateResolver do alias Sanbase.SheetsTemplate - alias Sanbase.Billing.{Subscription, Product} + alias Sanbase.Billing def get_sheets_templates(_root, _args, %{context: %{auth: %{current_user: user}}}) do - plan = - Subscription.current_subscription(user, Product.product_sanbase()) - |> Subscription.plan_name() + plan = Billing.sanbase_plan_name(user) {:ok, SheetsTemplate.get_all(%{is_logged_in: true, plan_name: plan})} end diff --git a/lib/sanbase_web/graphql/resolvers/signals/user_trigger_resolver.ex b/lib/sanbase_web/graphql/resolvers/signals/user_trigger_resolver.ex index 784e8a92b8..af44b47b07 100644 --- a/lib/sanbase_web/graphql/resolvers/signals/user_trigger_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/signals/user_trigger_resolver.ex @@ -6,8 +6,8 @@ defmodule SanbaseWeb.Graphql.Resolvers.UserTriggerResolver do import Absinthe.Resolution.Helpers, only: [on_load: 2] alias Sanbase.Accounts.User - alias Sanbase.Alert.{Trigger, UserTrigger} - alias Sanbase.Telegram + alias Sanbase.Alert.UserTrigger + alias Sanbase.Alerts alias SanbaseWeb.Graphql.SanbaseDataloader alias Sanbase.Billing.Plan.SanbaseAccessChecker @@ -173,26 +173,8 @@ defmodule SanbaseWeb.Graphql.Resolvers.UserTriggerResolver do # Private functions defp do_create_trigger(current_user, args) do - UserTrigger.create_user_trigger(current_user, args) + Alerts.create_trigger(current_user, args) |> handle_result("create") - |> case do - {:ok, result} -> - Telegram.send_message( - current_user, - """ - Successfully created a new alert of type: #{Trigger.human_readable_settings_type(args.settings["type"])} - - Title: #{args.title}#{if args[:description], do: "\nDescription: #{args[:description]}"} - - This bot will send you a message when the alert triggers πŸ€– - """ - ) - - {:ok, result} - - error -> - error - end end defp handle_result(result, operation) do diff --git a/lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex b/lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex index 04ba3ed51b..acca2dda92 100644 --- a/lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/user/auth_resolver.ex @@ -1,11 +1,5 @@ defmodule SanbaseWeb.Graphql.Resolvers.AuthResolver do - import Sanbase.Accounts.EventEmitter, only: [emit_event: 3] - - alias Sanbase.InternalServices.Ethauth - alias Sanbase.Accounts - alias Sanbase.Accounts.{User, EthAccount, EmailLoginAttempt, AccessAttempt, Turnstile} - - require Logger + alias Sanbase.Accounts.Auth def get_auth_sessions(_root, _args, %{context: %{auth: %{current_user: user}} = context}) do refresh_token = context[:jwt_tokens][:refresh_token] @@ -42,217 +36,38 @@ defmodule SanbaseWeb.Graphql.Resolvers.AuthResolver do end end - def eth_login( - _root, - %{signature: signature, address: address, message_hash: message_hash} = args, - %{context: %{device_data: device_data, origin_url: origin_url}} - ) do - event_args = %{login_origin: :eth_login, origin_url: origin_url} - - with true <- address_message_hash(address) == message_hash, - true <- Ethauth.valid_signature?(address, signature), - {:ok, user} <- fetch_user(args, EthAccount.by_address(address)), - first_login? <- User.RegistrationState.first_login?(user, "eth_login"), - {:ok, jwt_tokens} <- SanbaseWeb.Guardian.get_jwt_tokens(user, device_data), - {:ok, _, user} <- Sanbase.Accounts.forward_registration(user, "eth_login", event_args) do - user = %{user | first_login: first_login?} - emit_event({:ok, user}, :login_user, event_args) - - result = Map.take(jwt_tokens, [:access_token, :refresh_token]) |> Map.put(:user, user) - - {:ok, result} - else - {:error, %Ecto.Changeset{} = changeset} -> - Logger.warning("Login failed: #{inspect(changeset)}") - {:error, message: "Wallet Login verification failed"} - - {:error, reason} -> - Logger.warning("Login failed: #{inspect(reason)}") - {:error, message: "Wallet Login verification failed"} - - _ -> - Logger.warning("Login failed: invalid signature") - {:error, message: "Wallet Login verification failed"} - end + def eth_login(_root, args, %{context: %{device_data: device_data, origin_url: origin_url}}) do + Auth.eth_login(args, %{device_data: device_data, origin_url: origin_url}) end - def send_email_login_email(%{email: email} = args, %{ + def send_email_login_email(args, %{ context: %{ origin_url: origin_url, origin_host_parts: origin_host_parts, remote_ip: remote_ip } }) do - remote_ip = Sanbase.Utils.IP.ip_tuple_to_string(remote_ip) - - with :ok <- Turnstile.validate(args[:token], remote_ip), - true <- allowed_email_domain?(email), - true <- allowed_origin?(origin_host_parts, origin_url), - {:ok, %{first_login: first_login} = user} <- - User.find_or_insert_by(:email, email, %{username: args[:username]}), - :ok <- EmailLoginAttempt.check_attempt_limit(user, remote_ip), - {:ok, user} <- User.Email.update_email_token(user, args[:consent]), - {:ok, _res} <- User.Email.send_login_email(user, first_login, origin_host_parts, args), - {:ok, %AccessAttempt{}} <- AccessAttempt.create("email_login", user, remote_ip), - {:ok, _, user} <- - Accounts.forward_registration(user, "send_login_email", %{"origin_url" => origin_url}) do - emit_event({:ok, user}, :send_email_login_link, %{origin_url: origin_url}) - - {:ok, %{success: true}} - else - {:error, :invalid_redirect_url, message} -> - Logger.error( - "Login failed: #{message}. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" - ) - - {:error, message: message} - - {:error, :too_many_attempts} -> - Logger.info( - "Login failed: too many login attempts. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" - ) - - {:error, message: "Too many login attempts, try again after a few minutes"} - - {:error, error} when is_binary(error) -> - Logger.error( - "Login failed: #{error}. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" - ) - - {:error, message: error} - - error -> - Logger.error( - "Login failed: unknown error #{inspect(error)}. Email: #{email}, IP Address: #{remote_ip}, Origin URL: #{origin_url}" - ) - - {:error, message: "Can't login"} - end + Auth.send_login_email(args, %{ + origin_url: origin_url, + origin_host_parts: origin_host_parts, + remote_ip: remote_ip + }) end - def email_login_verify(%{token: token, email: email}, %{ - context: %{device_data: device_data, origin_url: origin_url} - }) do - args = %{login_origin: :email, origin_url: origin_url} - rand_id = :crypto.strong_rand_bytes(8) |> Base.encode32(case: :lower) |> binary_part(0, 10) - - with _ <- - Logger.info( - "[EmailLoginVerify][#{rand_id}] Start verification for #{email} with token #{String.slice(token, 0..5)}" - ), - {:ok, user} <- User.find_or_insert_by(:email, email), - _ <- Logger.info("[EmailLoginVerify][#{rand_id}] Found user with email #{email}"), - first_login? <- User.RegistrationState.first_login?(user, "email_login_verify"), - _ <- - Logger.info( - "[EmailLoginVerify][#{rand_id}] Start verification for #{email} with token #{String.slice(token, 0..5)}" - ), - true <- User.Email.email_token_valid?(user, token), - _ <- - Logger.info( - "[EmailLoginVerify][#{rand_id}] Verified token #{String.slice(token, 0..5)} for email #{email}" - ), - {:ok, jwt_tokens_map} <- SanbaseWeb.Guardian.get_jwt_tokens(user, device_data), - _ <- - Logger.info("[EmailLoginVerify][#{rand_id}] Created JWT tokens map for #{email}"), - {:ok, user} <- User.Email.mark_email_token_as_validated(user), - _ <- - Logger.info( - "[EmailLoginVerify][#{rand_id}] Marked login token for email #{email} as validated" - ), - {:ok, _, user} <- Accounts.forward_registration(user, "email_login_verify", args), - _ <- - Logger.info( - "[EmailLoginVerify][#{rand_id}] Updated the registration state for email #{email}" - ) do - Logger.info( - "[EmailLoginVerify][#{rand_id} Successfully logged in user with email #{email}]" - ) - - user = %{user | first_login: first_login?} - emit_event({:ok, user}, :login_user, args) - - result = Map.take(jwt_tokens_map, [:access_token, :refresh_token]) |> Map.put(:user, user) - - {:ok, result} - else - _ -> {:error, message: "Email Login verification failed"} - end + def email_login_verify(args, %{context: %{device_data: device_data, origin_url: origin_url}}) do + Auth.verify_email_login(args, %{device_data: device_data, origin_url: origin_url}) end - # Use the same rate limit as logins to track amount of emails send - # for email change def change_email(_root, %{email: email_candidate}, %{ context: %{ remote_ip: remote_ip, auth: %{auth_method: :user_token, current_user: user} } }) do - remote_ip = Sanbase.Utils.IP.ip_tuple_to_string(remote_ip) - - with :ok <- EmailLoginAttempt.check_attempt_limit(user, remote_ip), - {:ok, user} <- User.Email.update_email_candidate(user, email_candidate), - {:ok, _user} <- User.Email.send_verify_email(user), - {:ok, %AccessAttempt{}} <- EmailLoginAttempt.create(user, remote_ip) do - {:ok, %{success: true}} - else - {:error, error} -> - error_msg = "Can't change current user's email to #{email_candidate}" - Logger.info(error_msg <> ". Reason: #{inspect(error)}") - {:error, message: error_msg} - end - end - - def email_change_verify( - %{token: email_candidate_token, email_candidate: email_candidate}, - %{context: %{device_data: device_data}} - ) do - with {:ok, user} <- - User.Email.find_by_email_candidate(email_candidate, email_candidate_token), - true <- User.Email.email_candidate_token_valid?(user, email_candidate_token), - {:ok, jwt_tokens} <- SanbaseWeb.Guardian.get_jwt_tokens(user, device_data), - {:ok, user} <- User.Email.update_email_from_email_candidate(user) do - result = Map.take(jwt_tokens, [:access_token, :refresh_token]) |> Map.put(:user, user) - - {:ok, result} - else - _ -> {:error, message: "Email change verify failed"} - end - end - - defp allowed_origin?(["santiment", "net"] = _hosted_parts, _origin_url), do: true - defp allowed_origin?([_origin_app, "santiment", "net"] = _hosted_parts, _origin_url), do: true - - defp allowed_origin?(_hosted_parts, origin_url), - do: {:error, "Origin header #{origin_url} is not supported."} - - @blocked_domains ["burpcollaborator.net"] - defp allowed_email_domain?(email) do - domain = String.split(email, "@") |> Enum.at(1) - - case domain in @blocked_domains do - true -> {:error, "Email not supported."} - false -> true - end - end - - defp fetch_user(%{address: address}, nil) do - # No EthAccount and no user logged in. This means that the address is used - # for the first time. Create a User and create an EthAccount linked with - # the user. The username is automatically set to the address but is not - # used for logging in after that. - Accounts.create_user_with_eth_address(address) - end - - defp fetch_user(_args, %EthAccount{user_id: user_id}) do - # Existing EthAccount, login as the user of EthAccount - User.by_id(user_id) + Auth.change_email_request(user, email_candidate, remote_ip) end - defp address_message_hash(address) do - message = "Login in Santiment with address #{address}" - full_message = "\x19Ethereum Signed Message:\n" <> "#{String.length(message)}" <> message - hash = ExKeccak.hash_256(full_message) - "0x" <> Base.encode16(hash, case: :lower) + def email_change_verify(args, %{context: %{device_data: device_data}}) do + Auth.verify_email_change(args, %{device_data: device_data}) end end diff --git a/lib/sanbase_web/graphql/resolvers/user/linked_user_resolver.ex b/lib/sanbase_web/graphql/resolvers/user/linked_user_resolver.ex index da6650226e..b57ace136e 100644 --- a/lib/sanbase_web/graphql/resolvers/user/linked_user_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/user/linked_user_resolver.ex @@ -1,6 +1,6 @@ defmodule SanbaseWeb.Graphql.Resolvers.LinkedUserResolver do alias Sanbase.Accounts.User - alias Sanbase.Billing.{Product, Subscription} + alias Sanbase.Billing alias Sanbase.Accounts.{LinkedUser, LinkedUserCandidate} def generate_linked_users_token(_root, args, %{context: %{auth: %{current_user: user}}}) do @@ -24,15 +24,15 @@ defmodule SanbaseWeb.Graphql.Resolvers.LinkedUserResolver do end def primary_user_sanbase_subscription(_root, _args, %{context: %{auth: %{current_user: user}}}) do - Subscription.get_user_subscription(user.id, Product.product_sanbase()) + Billing.sanbase_subscription(user.id) end def primary_user_sanbase_subscription(%User{} = user, _args, _resolution) do - Subscription.get_user_subscription(user.id, Product.product_sanbase()) + Billing.sanbase_subscription(user.id) end def primary_user_sanbase_subscription(_root, _args, %{source: %{user: user}}) do - Subscription.get_user_subscription(user.id, Product.product_sanbase()) + Billing.sanbase_subscription(user.id) end def remove_secondary_user(_root, args, %{context: %{auth: %{current_user: user}}}) do diff --git a/lib/sanbase_web/graphql/resolvers/user/user_resolver.ex b/lib/sanbase_web/graphql/resolvers/user/user_resolver.ex index 528c2fd3e3..fc0b53de19 100644 --- a/lib/sanbase_web/graphql/resolvers/user/user_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/user/user_resolver.ex @@ -5,6 +5,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.UserResolver do import Absinthe.Resolution.Helpers, except: [async: 1] import SanbaseWeb.Graphql.Helpers.Utils, only: [requested_fields: 1] + alias Sanbase.Accounts alias Sanbase.InternalServices.Ethauth alias Sanbase.Accounts.User alias Sanbase.Accounts.UserFollower @@ -326,8 +327,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.UserResolver do {:ok, _acl} <- Sanbase.ApiCallLimit.reset(user), {:ok, _settings} <- UserSettings.update_self_reset_api_rate_limits_datetime(user, DateTime.utc_now()) do - user = Sanbase.Repo.preload(user, :user_settings, force: true) - {:ok, user} + {:ok, Accounts.reload_user_settings(user)} end end @@ -371,16 +371,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.UserResolver do def update_terms_and_conditions(_root, args, %{ context: %{auth: %{auth_method: :user_token, current_user: user}} }) do - # Update only the provided arguments - args = - args - |> Enum.reject(fn {_key, value} -> value == nil end) - |> Enum.into(%{}) - - user - |> User.changeset(args) - |> Sanbase.Repo.update() - |> case do + case Accounts.update_terms_and_conditions(user, args) do {:ok, user} -> {:ok, user} @@ -414,7 +405,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.UserResolver do end def update_profile(_root, args, %{context: %{auth: %{current_user: user}}}) do - case User.update(user, args) do + case Accounts.update_profile(user, args) do {:ok, user} -> {:ok, user} diff --git a/lib/sanbase_web/graphql/resolvers/vote_resolver.ex b/lib/sanbase_web/graphql/resolvers/vote_resolver.ex index bdc37ac654..f6455d2101 100644 --- a/lib/sanbase_web/graphql/resolvers/vote_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/vote_resolver.ex @@ -4,11 +4,6 @@ defmodule SanbaseWeb.Graphql.Resolvers.VoteResolver do alias SanbaseWeb.Graphql.SanbaseDataloader alias Sanbase.Accounts.User alias Sanbase.Vote - alias Sanbase.Insight.Post - alias Sanbase.Chart - alias Sanbase.Alert.UserTrigger - alias Sanbase.Timeline.TimelineEvent - alias Sanbase.UserList @doc ~s""" Returns a tuple `{total_votes, total_san_votes}` where: @@ -16,181 +11,39 @@ defmodule SanbaseWeb.Graphql.Resolvers.VoteResolver do - `total_san_votes` represents the number of votes where each vote's weight is equal to the san balance of the voter """ - def votes(%Post{} = post, _args, %{context: %{loader: loader} = context}) do + def votes(parent, _args, %{context: %{loader: loader} = context} = resolution) do user = get_in(context, [:auth, :current_user]) || %User{id: nil} - selector = %{post_id: post.id, user_id: user.id} - get_votes(loader, :insight_vote_stats, selector) - end - - def votes(%UserList{} = ul, _args, %{context: %{loader: loader} = context}) do - user = get_in(context, [:auth, :current_user]) || %User{id: nil} - selector = %{watchlist_id: ul.id, user_id: user.id} - get_votes(loader, :watchlist_vote_stats, selector) - end - - def votes(%Chart.Configuration{} = config, _args, %{ - context: %{loader: loader} = context - }) do - user = get_in(context, [:auth, :current_user]) || %User{id: nil} - selector = %{chart_configuration_id: config.id, user_id: user.id} - - get_votes(loader, :chart_configuration_vote_stats, selector) - end - - def votes(%Sanbase.Dashboards.Dashboard{} = dashboard, _args, %{ - context: %{loader: loader} = context - }) do - user = get_in(context, [:auth, :current_user]) || %User{id: nil} - selector = %{dashboard_id: dashboard.id, user_id: user.id} - - get_votes(loader, :dashboard_vote_stats, selector) - end - - def votes(%Sanbase.Queries.Query{} = query, _args, %{ - context: %{loader: loader} = context - }) do - user = get_in(context, [:auth, :current_user]) || %User{id: nil} - selector = %{query_id: query.id, user_id: user.id} - - get_votes(loader, :query_vote_stats, selector) - end - - def votes(%{trigger: %{id: user_trigger_id}}, _args, %{ - context: %{loader: loader} = context - }) - when is_integer(user_trigger_id) do - user = get_in(context, [:auth, :current_user]) || %User{id: nil} - selector = %{user_trigger_id: user_trigger_id, user_id: user.id} - - get_votes(loader, :user_trigger_vote_stats, selector) - end - - def votes(%UserTrigger{id: user_trigger_id}, _args, %{ - context: %{loader: loader} = context - }) - when is_integer(user_trigger_id) do - user = get_in(context, [:auth, :current_user]) || %User{id: nil} - selector = %{user_trigger_id: user_trigger_id, user_id: user.id} - - get_votes(loader, :user_trigger_vote_stats, selector) - end - - def votes(%TimelineEvent{} = event, _args, %{ - context: %{loader: loader} = context - }) do - user = get_in(context, [:auth, :current_user]) || %User{id: nil} - selector = %{timeline_event_id: event.id, user_id: user.id} - - get_votes(loader, :timeline_event_vote_stats, selector) - end - - def votes(_root, args, %{source: %{post_id: id}} = resolution) do - # Handles the case where the `votes` is called on top of the result - # from `vote`/`unvote`. They return the entity id as a result which - # can be used from the `source` map in the resolution - votes(%Post{id: id}, args, resolution) - end - - def votes(_root, args, %{source: %{watchlist_id: id}} = resolution) do - # Handles the case where the `votes` is called on top of the result - # from `vote`/`unvote`. They return the entity id as a result which - # can be used from the `source` map in the resolution - votes(%UserList{id: id}, args, resolution) - end - - def votes(_root, args, %{source: %{chart_configuration_id: id}} = resolution) do - # Handles the case where the `votes` is called on top of the result - # from `vote`/`unvote`. They return the entity id as a result which - # can be used from the `source` map in the resolution - votes(%Chart.Configuration{id: id}, args, resolution) - end - def votes(_root, args, %{source: %{dashboard_id: id}} = resolution) do - # Handles the case where the `votes` is called on top of the result - # from `vote`/`unvote`. They return the entity id as a result which - # can be used from the `source` map in the resolution - votes(%Sanbase.Dashboards.Dashboard{id: id}, args, resolution) - end - - def votes(_root, args, %{source: %{query_id: id}} = resolution) do - # Handles the case where the `votes` is called on top of the result - # from `vote`/`unvote`. They return the entity id as a result which - # can be used from the `source` map in the resolution - votes(%Sanbase.Queries.Query{id: id}, args, resolution) - end - - def votes(_root, args, %{source: %{user_trigger_id: id}} = resolution) do - # Handles the case where the `votes` is called on top of the result - # from `vote`/`unvote`. They return the entity id as a result which - # can be used from the `source` map in the resolution - votes(%UserTrigger{id: id}, args, resolution) - end - - def votes(_root, args, %{source: %{timeline_event_id: id}} = resolution) do - # Handles the case where the `votes` is called on top of the result - # from `vote`/`unvote`. They return the entity id as a result which - # can be used from the `source` map in the resolution - votes(%TimelineEvent{id: id}, args, resolution) - end + case resolve_entity(parent, resolution) do + {entity_id, selector_key, votes_query, _voted_at_query} -> + get_votes(loader, votes_query, %{selector_key => entity_id, :user_id => user.id}) - def voted_at(%Post{} = post, _args, %{ - context: %{loader: loader, auth: %{current_user: user}} - }) do - selector = %{post_id: post.id, user_id: user.id} - get_voted_at(loader, :insight_voted_at, selector) - end - - def voted_at(%UserList{} = ul, _args, %{ - context: %{loader: loader, auth: %{current_user: user}} - }) do - selector = %{watchlist_id: ul.id, user_id: user.id} - get_voted_at(loader, :watchlist_voted_at, selector) - end - - def voted_at(%TimelineEvent{} = event, _args, %{ - context: %{loader: loader, auth: %{current_user: user}} - }) do - selector = %{timeline_event_id: event.id, user_id: user.id} - get_voted_at(loader, :timeline_event_voted_at, selector) + nil -> + {:ok, nil} + end end - def voted_at(%Chart.Configuration{} = config, _args, %{ - context: %{loader: loader, auth: %{current_user: user}} - }) do - selector = %{chart_configuration_id: config.id, user_id: user.id} - get_voted_at(loader, :chart_configuration_voted_at, selector) - end + def voted_at( + parent, + _args, + %{context: %{loader: loader, auth: %{current_user: user}}} = resolution + ) do + case resolve_entity(parent, resolution) do + {entity_id, selector_key, _votes_query, voted_at_query} -> + get_voted_at(loader, voted_at_query, %{selector_key => entity_id, :user_id => user.id}) - def voted_at(%Sanbase.Dashboards.Dashboard{} = config, _args, %{ - context: %{loader: loader, auth: %{current_user: user}} - }) do - selector = %{dashboard_id: config.id, user_id: user.id} - get_voted_at(loader, :dashboard_voted_at, selector) - end - - def voted_at(%Sanbase.Queries.Query{} = config, _args, %{ - context: %{loader: loader, auth: %{current_user: user}} - }) do - selector = %{query_id: config.id, user_id: user.id} - get_voted_at(loader, :query_voted_at, selector) + nil -> + {:ok, nil} + end end - def voted_at(%{trigger: %{id: id}}, _args, %{ - context: %{loader: loader, auth: %{current_user: user}} - }) do - selector = %{user_trigger_id: id, user_id: user.id} - get_voted_at(loader, :user_trigger_voted_at, selector) - end + def voted_at(_root, _args, _context), do: {:ok, nil} - def voted_at(%UserTrigger{id: id}, _args, %{ - context: %{loader: loader, auth: %{current_user: user}} - }) do - selector = %{user_trigger_id: id, user_id: user.id} - get_voted_at(loader, :user_trigger_voted_at, selector) + defp resolve_entity(parent, resolution) do + Vote.dataloader_keys(parent) || + Vote.dataloader_keys(Map.get(resolution, :source) || %{}) end - def voted_at(_root, _args, _context), do: {:ok, nil} - # Private functions defp get_votes(loader, query, selector) do loader diff --git a/lib/sanbase_web/graphql/resolvers/webinar_resolver.ex b/lib/sanbase_web/graphql/resolvers/webinar_resolver.ex index 4af4055cc5..f76a8fa3e4 100644 --- a/lib/sanbase_web/graphql/resolvers/webinar_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/webinar_resolver.ex @@ -1,12 +1,9 @@ defmodule SanbaseWeb.Graphql.Resolvers.WebinarResolver do - alias Sanbase.Webinar - alias Sanbase.Webinars.Registration - alias Sanbase.Billing.{Subscription, Product} + alias Sanbase.Webinars.{Webinar, Registration} + alias Sanbase.Billing def get_webinars(_root, _args, %{context: %{auth: %{current_user: user}}}) do - plan = - Subscription.current_subscription(user, Product.product_sanbase()) - |> Subscription.plan_name() + plan = Billing.sanbase_plan_name(user) {:ok, Webinar.get_all(%{is_logged_in: true, plan_name: plan})} end diff --git a/lib/sanbase_web/live/admin/ai_description_live.ex b/lib/sanbase_web/live/admin/ai_description_live.ex index 1d768d31b9..fd7f4b1c60 100644 --- a/lib/sanbase_web/live/admin/ai_description_live.ex +++ b/lib/sanbase_web/live/admin/ai_description_live.ex @@ -1,15 +1,9 @@ defmodule SanbaseWeb.Admin.AiDescriptionLive do use SanbaseWeb, :live_view - import Ecto.Query - - alias Sanbase.Repo - alias Sanbase.Accounts.User - alias Sanbase.Insight.Post - alias Sanbase.Chart.Configuration - alias Sanbase.UserList - alias Sanbase.Accounts.UserSettings + alias Sanbase.AI.ContentCandidates alias Sanbase.AI.DescriptionJob + alias Sanbase.Accounts.UserSettings @default_page_size 20 @allowed_entity_types [:charts, :screeners, :watchlists, :insights] @@ -73,7 +67,7 @@ defmodule SanbaseWeb.Admin.AiDescriptionLive do def handle_event("search_user", params, socket) do query = String.trim(Map.get(params, "query", Map.get(params, "value", ""))) - results = if String.length(query) >= 2, do: search_users(query), else: [] + results = if String.length(query) >= 2, do: ContentCandidates.search_users(query), else: [] socket = socket @@ -266,7 +260,7 @@ defmodule SanbaseWeb.Admin.AiDescriptionLive do else pending = [:charts, :screeners, :watchlists, :insights] - |> Enum.flat_map(&fetch_all_pending(&1, user.id)) + |> Enum.flat_map(&ContentCandidates.pending_ids(&1, user.id)) if pending == [] do {:noreply, put_flash(socket, :info, "All entities already have AI descriptions")} @@ -309,7 +303,7 @@ defmodule SanbaseWeb.Admin.AiDescriptionLive do {:noreply, socket |> assign(:show_override_confirm, false) |> put_flash(:error, "No user selected")} else - {count, _} = override_descriptions(entity_type, user.id) + {count, _} = ContentCandidates.override_descriptions(entity_type, user.id) socket = socket @@ -486,8 +480,7 @@ defmodule SanbaseWeb.Admin.AiDescriptionLive do %{entity_type: entity_type, page: page, page_size: page_size, selected_user: user} = socket.assigns - offset = (page - 1) * page_size - {entities, total_count} = fetch_entities(entity_type, user.id, page_size, offset) + {entities, total_count} = ContentCandidates.list(entity_type, user.id, page, page_size) total_pages = max(1, ceil(total_count / page_size)) tab_counts = @@ -495,7 +488,7 @@ defmodule SanbaseWeb.Admin.AiDescriptionLive do |> Map.new(fn type -> if type == entity_type, do: {type, total_count}, - else: {type, count_entities(type, user.id)} + else: {type, ContentCandidates.count(type, user.id)} end) socket @@ -505,201 +498,6 @@ defmodule SanbaseWeb.Admin.AiDescriptionLive do |> assign(:tab_counts, tab_counts) end - defp fetch_entities(:insights, user_id, limit, offset) do - base = - from(p in Post, - where: p.is_deleted == false and p.user_id == ^user_id, - preload: [:user], - order_by: [desc: p.inserted_at] - ) - - count = Repo.aggregate(base, :count, :id) - entities = Repo.all(from(q in base, limit: ^limit, offset: ^offset)) - {entities, count} - end - - defp fetch_entities(:charts, user_id, limit, offset) do - base = - from(c in Configuration, - where: c.is_deleted == false and c.user_id == ^user_id, - preload: [:user], - order_by: [desc: c.inserted_at] - ) - - count = Repo.aggregate(base, :count, :id) - entities = Repo.all(from(q in base, limit: ^limit, offset: ^offset)) - {entities, count} - end - - defp fetch_entities(:screeners, user_id, limit, offset) do - base = - from(ul in UserList, - where: ul.is_deleted == false and ul.is_screener == true and ul.user_id == ^user_id, - preload: [:user], - order_by: [desc: ul.inserted_at] - ) - - count = Repo.aggregate(base, :count, :id) - entities = Repo.all(from(q in base, limit: ^limit, offset: ^offset)) - {entities, count} - end - - defp fetch_entities(:watchlists, user_id, limit, offset) do - base = - from(ul in UserList, - where: ul.is_deleted == false and ul.is_screener == false and ul.user_id == ^user_id, - preload: [:user], - order_by: [desc: ul.inserted_at] - ) - - count = Repo.aggregate(base, :count, :id) - entities = Repo.all(from(q in base, limit: ^limit, offset: ^offset)) - {entities, count} - end - - # Returns {id, type} pairs only β€” full records are loaded in batches inside DescriptionJob. - defp fetch_all_pending(:insights, user_id) do - Repo.all( - from(p in Post, - where: p.is_deleted == false and p.user_id == ^user_id and is_nil(p.ai_description), - order_by: [desc: p.inserted_at], - select: p.id - ) - ) - |> Enum.map(&{&1, :insights}) - end - - defp fetch_all_pending(:charts, user_id) do - Repo.all( - from(c in Configuration, - where: c.is_deleted == false and c.user_id == ^user_id and is_nil(c.ai_description), - order_by: [desc: c.inserted_at], - select: c.id - ) - ) - |> Enum.map(&{&1, :charts}) - end - - defp fetch_all_pending(:screeners, user_id) do - Repo.all( - from(ul in UserList, - where: - ul.is_deleted == false and ul.is_screener == true and ul.user_id == ^user_id and - is_nil(ul.ai_description), - order_by: [desc: ul.inserted_at], - select: ul.id - ) - ) - |> Enum.map(&{&1, :screeners}) - end - - defp fetch_all_pending(:watchlists, user_id) do - Repo.all( - from(ul in UserList, - where: - ul.is_deleted == false and ul.is_screener == false and ul.user_id == ^user_id and - is_nil(ul.ai_description), - order_by: [desc: ul.inserted_at], - select: ul.id - ) - ) - |> Enum.map(&{&1, :watchlists}) - end - - defp count_entities(:insights, user_id) do - Repo.aggregate( - from(p in Post, where: p.is_deleted == false and p.user_id == ^user_id), - :count, - :id - ) - end - - defp count_entities(:charts, user_id) do - Repo.aggregate( - from(c in Configuration, where: c.is_deleted == false and c.user_id == ^user_id), - :count, - :id - ) - end - - defp count_entities(:screeners, user_id) do - Repo.aggregate( - from(ul in UserList, - where: ul.is_deleted == false and ul.is_screener == true and ul.user_id == ^user_id - ), - :count, - :id - ) - end - - defp count_entities(:watchlists, user_id) do - Repo.aggregate( - from(ul in UserList, - where: ul.is_deleted == false and ul.is_screener == false and ul.user_id == ^user_id - ), - :count, - :id - ) - end - - defp search_users(query) do - query = String.trim(query) - - case Integer.parse(query) do - {user_id, ""} -> - # Numeric input β€” search by ID - Repo.all(from(u in User, where: u.id == ^user_id, limit: 10)) - - _ -> - # Text input β€” search by username or email (case-insensitive partial match) - pattern = "%#{String.downcase(query)}%" - - Repo.all( - from(u in User, - where: - fragment("lower(?) LIKE ?", u.username, ^pattern) or - fragment("lower(?) LIKE ?", u.email, ^pattern), - order_by: u.id, - limit: 10 - ) - ) - end - end - - defp override_descriptions(:insights, user_id) do - Repo.update_all( - from(p in Post, - where: p.user_id == ^user_id and p.is_deleted == false and not is_nil(p.ai_description), - update: [set: [short_desc: p.ai_description]] - ), - [] - ) - end - - defp override_descriptions(:charts, user_id) do - Repo.update_all( - from(c in Configuration, - where: c.user_id == ^user_id and c.is_deleted == false and not is_nil(c.ai_description), - update: [set: [description: c.ai_description]] - ), - [] - ) - end - - defp override_descriptions(type, user_id) when type in [:screeners, :watchlists] do - screener_flag = type == :screeners - - Repo.update_all( - from(ul in UserList, - where: - ul.user_id == ^user_id and ul.is_deleted == false and ul.is_screener == ^screener_flag and - not is_nil(ul.ai_description), - update: [set: [description: ul.ai_description]] - ), - [] - ) - end - defp update_entity_ai_desc(entities, id, ai_description) do Enum.map(entities, fn e -> if e.id == id, do: Map.put(e, :ai_description, ai_description), else: e @@ -794,7 +592,7 @@ defmodule SanbaseWeb.Admin.AiDescriptionLive do if socket.assigns.selected_user && socket.assigns.selected_user.id == user_id do socket else - case Repo.get(User, user_id) do + case ContentCandidates.get_user(user_id) do nil -> socket |> assign(:selected_user, nil) diff --git a/test/support/factory/factory.ex b/test/support/factory/factory.ex index 9d211ba061..c4d4bf9ac3 100644 --- a/test/support/factory/factory.ex +++ b/test/support/factory/factory.ex @@ -26,7 +26,7 @@ defmodule Sanbase.Factory do alias Sanbase.Report alias Sanbase.BlockchainAddress alias Sanbase.SheetsTemplate - alias Sanbase.Webinar + alias Sanbase.Webinars.Webinar alias Sanbase.Accounts.Interaction def intercation_factory do From 6b09c87590a4f7bd2c990f3560c40f3c7c531970 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Fri, 22 May 2026 17:09:15 +0300 Subject: [PATCH 2/3] Tidy auth, billing, insights, and AI candidates modules --- lib/sanbase/accounts/auth.ex | 27 +++++++++---------- lib/sanbase/ai/content_candidates.ex | 12 ++++++--- lib/sanbase/billing/billing.ex | 8 ------ .../graphql/resolvers/insight_resolver.ex | 15 +++++------ 4 files changed, 26 insertions(+), 36 deletions(-) diff --git a/lib/sanbase/accounts/auth.ex b/lib/sanbase/accounts/auth.ex index b19d5ad866..a5069a9ad3 100644 --- a/lib/sanbase/accounts/auth.ex +++ b/lib/sanbase/accounts/auth.ex @@ -111,17 +111,13 @@ defmodule Sanbase.Accounts.Auth do args = %{login_origin: :email, origin_url: origin_url} rand_id = :crypto.strong_rand_bytes(8) |> Base.encode32(case: :lower) |> binary_part(0, 10) - with _ <- - Logger.info( - "[EmailLoginVerify][#{rand_id}] Start verification for #{email} with token #{String.slice(token, 0..5)}" - ), - {:ok, user} <- User.find_or_insert_by(:email, email), + Logger.info( + "[EmailLoginVerify][#{rand_id}] Start verification for #{email} with token #{String.slice(token, 0..5)}" + ) + + with {:ok, user} <- User.find_or_insert_by(:email, email), _ <- Logger.info("[EmailLoginVerify][#{rand_id}] Found user with email #{email}"), first_login? <- User.RegistrationState.first_login?(user, "email_login_verify"), - _ <- - Logger.info( - "[EmailLoginVerify][#{rand_id}] Start verification for #{email} with token #{String.slice(token, 0..5)}" - ), true <- User.Email.email_token_valid?(user, token), _ <- Logger.info( @@ -161,7 +157,7 @@ defmodule Sanbase.Accounts.Auth do with :ok <- EmailLoginAttempt.check_attempt_limit(user, remote_ip), {:ok, user} <- User.Email.update_email_candidate(user, email_candidate), {:ok, _user} <- User.Email.send_verify_email(user), - {:ok, %AccessAttempt{}} <- EmailLoginAttempt.create(user, remote_ip) do + {:ok, _} <- EmailLoginAttempt.create(user, remote_ip) do {:ok, %{success: true}} else {:error, error} -> @@ -188,8 +184,8 @@ defmodule Sanbase.Accounts.Auth do end end - defp allowed_origin?(["santiment", "net"] = _hosted_parts, _origin_url), do: true - defp allowed_origin?([_origin_app, "santiment", "net"] = _hosted_parts, _origin_url), do: true + defp allowed_origin?(["santiment", "net"], _origin_url), do: true + defp allowed_origin?([_origin_app, "santiment", "net"], _origin_url), do: true defp allowed_origin?(_hosted_parts, origin_url), do: {:error, "Origin header #{origin_url} is not supported."} @@ -197,9 +193,10 @@ defmodule Sanbase.Accounts.Auth do defp allowed_email_domain?(email) do domain = String.split(email, "@") |> Enum.at(1) - case domain in @blocked_domains do - true -> {:error, "Email not supported."} - false -> true + if domain in @blocked_domains do + {:error, "Email not supported."} + else + true end end diff --git a/lib/sanbase/ai/content_candidates.ex b/lib/sanbase/ai/content_candidates.ex index 492365d5d0..d63e5e72d8 100644 --- a/lib/sanbase/ai/content_candidates.ex +++ b/lib/sanbase/ai/content_candidates.ex @@ -21,12 +21,16 @@ defmodule Sanbase.AI.ContentCandidates do @spec list(entity_type(), non_neg_integer(), non_neg_integer(), non_neg_integer()) :: {list(), non_neg_integer()} def list(type, user_id, page, page_size) do - limit = page_size offset = (page - 1) * page_size - base = list_query(type, user_id) - count = Repo.aggregate(base, :count, :id) - entities = Repo.all(from(q in base, limit: ^limit, offset: ^offset)) + count = Repo.aggregate(count_query(type, user_id), :count, :id) + + entities = + list_query(type, user_id) + |> limit(^page_size) + |> offset(^offset) + |> Repo.all() + {entities, count} end diff --git a/lib/sanbase/billing/billing.ex b/lib/sanbase/billing/billing.ex index 96f5b3f619..bf78470474 100644 --- a/lib/sanbase/billing/billing.ex +++ b/lib/sanbase/billing/billing.ex @@ -167,14 +167,6 @@ defmodule Sanbase.Billing do end end - # ────────────────────────────────────────────────────────────────── - # Stripe-facing operations - # - # The web/resolver layer should call these wrappers rather than - # `Sanbase.StripeApi` directly. Each wrapper hides Stripe.* structs - # from callers and returns plain maps or already-mapped errors. - # ────────────────────────────────────────────────────────────────── - @doc ~s""" Fetch the latest Stripe state for the user's subscription and sync the local record (used when the UI needs the freshest payment-intent client secret). diff --git a/lib/sanbase_web/graphql/resolvers/insight_resolver.ex b/lib/sanbase_web/graphql/resolvers/insight_resolver.ex index c7994f93cc..fd0eb32656 100644 --- a/lib/sanbase_web/graphql/resolvers/insight_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/insight_resolver.ex @@ -8,7 +8,6 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do alias Sanbase.Comments.EntityComment @list_opt_keys [:is_pulse, :is_paywall_required, :from, :to] - @list_opt_keys_with_categories [:is_pulse, :is_paywall_required, :categories, :from, :to] def popular_insight_authors(_root, _args, _resolution), do: Insights.popular_authors() @@ -131,16 +130,14 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do def create_chart_event(_root, args, %{context: %{auth: %{current_user: user}}}), do: Insights.create_chart_event(user.id, args) - defp list_opts(args, page, page_size, :categories) do - args - |> Map.take(@list_opt_keys_with_categories) - |> Map.to_list() - |> Keyword.merge(page: page, page_size: page_size) - end + defp list_opts(args, page, page_size, extra_keys \\ []) + + defp list_opts(args, page, page_size, :categories), + do: list_opts(args, page, page_size, [:categories]) - defp list_opts(args, page, page_size) do + defp list_opts(args, page, page_size, extra_keys) when is_list(extra_keys) do args - |> Map.take(@list_opt_keys) + |> Map.take(@list_opt_keys ++ extra_keys) |> Map.to_list() |> Keyword.merge(page: page, page_size: page_size) end From 9af0d782ff4fda362c1dd201e1269e5722aad7f5 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Mon, 1 Jun 2026 12:37:10 +0300 Subject: [PATCH 3/3] Trim unused facade surface from context-module refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MetricRegistry faΓ§ade shipped 27 delegates but only 4 have a consumer (metric_display_order_resolver); drop the 23 unused ones so the faΓ§ade reflects its actual call sites. Likewise remove the unused Insights.pulse?/1 delegate β€” pulse_text/1 calls Post.pulse?/1 directly and nothing else references it. Behavior-preserving: no call site referenced the removed functions (verified by grep + a full test run across the touched facades). Co-Authored-By: Claude Opus 4.8 --- lib/sanbase/insights/insights.ex | 1 - lib/sanbase/metric/metric_registry_facade.ex | 72 +++----------------- 2 files changed, 11 insertions(+), 62 deletions(-) diff --git a/lib/sanbase/insights/insights.ex b/lib/sanbase/insights/insights.ex index 13c4dbba11..0d7b8268f2 100644 --- a/lib/sanbase/insights/insights.ex +++ b/lib/sanbase/insights/insights.ex @@ -25,7 +25,6 @@ defmodule Sanbase.Insights do defdelegate user_voted_insights(user_id, opts), to: Post, as: :all_insights_user_voted_for defdelegate related_projects(post), to: Post - defdelegate pulse?(post), to: Post @doc "Pulse insights expose their text via this field; non-pulse insights get nil." @spec pulse_text(Post.t()) :: {:ok, String.t() | nil} diff --git a/lib/sanbase/metric/metric_registry_facade.ex b/lib/sanbase/metric/metric_registry_facade.ex index 01446af0e9..97acc68d7a 100644 --- a/lib/sanbase/metric/metric_registry_facade.ex +++ b/lib/sanbase/metric/metric_registry_facade.ex @@ -1,70 +1,26 @@ defmodule Sanbase.MetricRegistry do @moduledoc ~s""" - Public faΓ§ade for the metric registry domain. + Public faΓ§ade for the metric-registry display/categorization read paths used + by the web layer. The metric registry is split across many submodules β€” `Sanbase.Metric.Registry` - holds the canonical metric definitions; `Sanbase.Metric.Registry.Changelog`, - `.MetricVersions`, `.ChangeSuggestion`, `.Sync` cover historical/diff/sync - views; `Sanbase.Metric.Category` and `Sanbase.Metric.UIMetadata.*` provide the - human-facing categorization and display ordering. Web/LiveView callers should - use this module rather than reaching into the internals directly, so the - interaction surface stays small as the schemas evolve. - - This module is a thin shim around those submodules. It does not own state of - its own; the underlying modules remain the source of truth and are still the - right place for behavior changes. + holds the canonical metric definitions, `Sanbase.Metric.Category` and + `Sanbase.Metric.UIMetadata.*` provide the human-facing categorization and + display ordering. Web/LiveView callers should reach for this module rather + than the internals, so the interaction surface stays small as the schemas + evolve. + + This module is a thin shim around those submodules; it owns no state of its + own. Delegates are added here as call sites migrate onto the faΓ§ade β€” keep it + limited to functions that actually have a consumer. """ - alias Sanbase.Metric.Registry - alias Sanbase.Metric.Registry.{Changelog, ChangeSuggestion, MetricVersions, Sync} alias Sanbase.Metric.Category alias Sanbase.Metric.UIMetadata - # ── Registry CRUD/lookup ────────────────────────────────────────────── - defdelegate all(), to: Registry - defdelegate by_id(id), to: Registry - defdelegate by_ids(ids), to: Registry - defdelegate aggregations(), to: Registry - defdelegate allowed_statuses(), to: Registry - defdelegate resolve(list), to: Registry - defdelegate resolve_safe(list), to: Registry - defdelegate update_is_verified(registry, is_verified), to: Registry - - # ── Changelog / versions / suggestions / sync ───────────────────────── - defdelegate changelog_by_metric_registry_id(id), to: Changelog, as: :by_metric_registry_id - - defdelegate changelog_state_before_last_sync(metric_registry_id, last_sync_datetime), - to: Changelog, - as: :state_before_last_sync - - defdelegate metric_registry_ids_with_changes(), to: Changelog - - defdelegate metric_versions_changelog(limit, offset, search_term \\ nil), - to: MetricVersions, - as: :get_changelog_by_date - - defdelegate change_suggestion_update_status(id, new_status), - to: ChangeSuggestion, - as: :update_status - - defdelegate sync_apply(params), to: Sync, as: :apply_sync - defdelegate sync_by_uuid(uuid, sync_type), to: Sync, as: :by_uuid - defdelegate sync_cancel_run(uuid, sync_type), to: Sync, as: :cancel_run - defdelegate sync_last_runs(limit), to: Sync, as: :last_syncs - - defdelegate sync_mark_completed(sync_uuid, actual_changes), - to: Sync, - as: :mark_sync_as_completed - - defdelegate sync_run(metric_registry_ids, opts \\ []), to: Sync, as: :sync - # ── Categorization (DB-backed Metric.Category) ──────────────────────── defdelegate category_ordered_metrics(), to: Category, as: :get_ordered_metrics - defdelegate category_mappings_by_metric_registry_id(id), - to: Category, - as: :get_mappings_by_metric_registry_id - # ── UI metadata categories and groups ───────────────────────────────── defdelegate ui_category_by_name(name), to: UIMetadata.Category, as: :by_name @@ -72,13 +28,7 @@ defmodule Sanbase.MetricRegistry do to: UIMetadata.Group, as: :by_name_and_category - defdelegate ui_groups_by_category(category_id), to: UIMetadata.Group, as: :by_category - defdelegate ui_group_delete(group), to: UIMetadata.Group, as: :delete - defdelegate ui_display_order_ordered_metrics(), to: UIMetadata.DisplayOrder, as: :get_ordered_metrics - - # ── Helper (registered metric modules) ──────────────────────────────── - defdelegate metric_modules(), to: Sanbase.Metric.Helper end