diff --git a/.formatter.exs b/.formatter.exs index 54fbb910ac..f5dc212279 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,14 @@ [ - import_deps: [:ecto, :ecto_sql, :phoenix], + inputs: [ + "config/*.{ex,exs}", + "lib/*.{ex,exs}", + "lib/**/*.{ex,exs}", + "test/*.{ex,exs}", + "test/**/*.{ex,exs}", + "priv/**/*.{ex,exs}", + "mix.exs", + ".formatter.exs" + ], plugins: [Phoenix.LiveView.HTMLFormatter], - inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"], - subdirectories: ["priv/*/migrations"] + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] ] diff --git a/lib/sanbase/menu/menus.ex b/lib/sanbase/menu/menus.ex new file mode 100644 index 0000000000..16de3a4b60 --- /dev/null +++ b/lib/sanbase/menu/menus.ex @@ -0,0 +1,375 @@ +defmodule Sanbase.Menus do + @moduledoc ~s""" + Boundary module for working with menus. + + A menu is an ordered list of menu items. Each menu item can be: + - Query; + - Dashboard; + - Menu A sub-menu can also have a list of menu items, in order to build + nesting and hierarchies. + + When the menu is returned by the GraphQL API, the menu_to_simple_map/1 function + is used in order to transform the menu struct to a structure that can be directly + translated to JSON. This menu representation contains only the type, id, name and + description of each menu item, as well as the position in the menu. + """ + alias Sanbase.Menus.Menu + alias Sanbase.Menus.MenuItem + alias Sanbase.Repo + + import Sanbase.Utils.ErrorHandling, only: [changeset_errors_string: 1] + + @type parent_menu_id :: non_neg_integer() + @type user_id :: Sanbase.Accounts.User.user_id() + @type menu_id :: Menu.menu_id() + @type menu_item_id :: MenuItem.menu_item_id() + + @type create_menu_params :: %{ + required(:name) => String.t(), + optional(:description) => String.t(), + optional(:parent_id) => integer(), + optional(:position) => integer() + } + + @type update_menu_params :: %{ + optional(:name) => String.t(), + optional(:description) => String.t() + } + + @type create_menu_item_params :: %{ + required(:parent_id) => menu_id, + optional(:position) => integer() | nil, + optional(:query_id) => Sanbase.Queries.Query.query_id(), + optional(:dashboard_id) => Sanbase.Queries.Dashboard.dashboard_id(), + optional(:menu_id) => menu_id + } + + @type update_menu_item_params :: %{ + optional(:parent_id) => menu_id, + optional(:position) => integer() | nil + } + + @doc ~s""" + Get a menu by its id and preloaded 2 levels of nesting. + """ + def get_menu(menu_id, user_id) do + query = Menu.by_id(menu_id, user_id) + + case Repo.one(query) do + nil -> {:error, "Menu with id #{menu_id} not found"} + menu -> {:ok, menu} + end + end + + @doc ~s""" + Convert a menu with preloaded menu items to a map in the format. This format + can directly be returned by the GraphQL API if the return type is `:json` + + %{ + entity: :menu, id: 1, name: "N", description: "D", menu_items: [ + %{entity_type: :query, id: 2, name: "Q", description: "D", position: 1}, + %{entity_type: :dashboard, id: 21, name: "D", description: "D", position: 2} + ] + } + """ + def menu_to_simple_map(%Menu{} = menu) do + %{ + # If this menu is a sub-menu, then the caller from get_menu_items/1 will + # additionally set the menu_item_id. If this is the top-level menu, then + # this is not a sub-menu and it does not have a menu_item_id + menu_item_id: nil, + type: :menu, + id: menu.id, + name: menu.name, + description: menu.description, + menu_items: get_menu_items(menu) + } + |> recursively_order_menu_items() + end + + @doc ~s""" + Create a new menu. + + A menu has a name and a description. It holds a list of MenuItems that have a given + order. The menu params can also have a `parent_id` and `position` which indicates that this menu + is created as a sub-menu of that parent. + """ + @spec create_menu(create_menu_params, user_id) :: {:ok, Menu.t()} | {:error, String.t()} + def create_menu(params, user_id) do + params = + params + |> Map.merge(%{user_id: user_id}) + + Ecto.Multi.new() + |> Ecto.Multi.run(:create_menu, fn _repo, _changes -> + query = Menu.create(params) + Repo.insert(query) + end) + |> Ecto.Multi.run(:maybe_create_menu_item, fn _repo, %{create_menu: menu} -> + # If the params have `:parent_id`, then this menu is a sub-menu, + # which is done by adding a record to the menu_items table. + case Map.get(params, :parent_id) do + nil -> + {:ok, nil} + + parent_id -> + # Add this new menu as a menu item to the parent + create_menu_item( + %{ + parent_id: parent_id, + menu_id: menu.id, + position: Map.get(params, :position) + }, + user_id + ) + end + end) + |> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{create_menu: menu} -> + # There would be no menu items, but this will help to set the menu items to [] + # instead of getting an error when trying to iterate them because they're set to + get_menu(menu.id, user_id) + end) + |> Repo.transaction() + |> process_transaction_result(:get_menu_with_preloads) + end + + @doc ~s""" + Update an existing menu. + + The name, description, parent_id and position can be updated. + """ + @spec update_menu(menu_id, update_menu_params, user_id) :: + {:ok, Menu.t()} | {:error, String.t()} + def update_menu(menu_id, params, user_id) do + Ecto.Multi.new() + |> Ecto.Multi.run(:get_menu_for_update, fn _repo, _changes -> + get_menu_for_update(menu_id, user_id) + end) + |> Ecto.Multi.run(:update_menu, fn _repo, %{get_menu_for_update: menu} -> + query = Menu.update(menu, params) + Repo.update(query) + end) + |> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{update_menu: menu} -> + get_menu(menu.id, user_id) + end) + |> Repo.transaction() + |> process_transaction_result(:get_menu_with_preloads) + end + + @doc ~s""" + Delete a menu + """ + @spec delete_menu(menu_id, user_id) :: {:ok, Menu.t()} | {:error, String.t()} + def delete_menu(menu_id, user_id) do + Ecto.Multi.new() + |> Ecto.Multi.run(:get_menu_for_update, fn _repo, _changes -> + get_menu_for_update(menu_id, user_id) + end) + |> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, _changes -> + # Call this so we can return the menu with its menu items after it is + # successfully deleted + get_menu(menu_id, user_id) + end) + |> Ecto.Multi.run(:delete_menu, fn _repo, %{get_menu_for_update: menu} -> + Repo.delete(menu) + end) + |> Repo.transaction() + # Purposefully do not return the result of the last Ecto.Multi.run call, + # but from the get_menu_with_preloads call, so we can return the menu with + # its items. + |> process_transaction_result(:get_menu_with_preloads) + end + + @doc ~s""" + Create a new menu item. + + The menu item can be: + - Query + - Dashboard + - Menu (to build hierarchies) + + Each item has a `position`. If no position is specified, it will be appended at the end. + If a position is specified, all the positions bigger than it will be bumped by 1 in order + to accomodate the new item. + """ + @spec create_menu_item(create_menu_item_params, user_id) :: + {:ok, Menu.t()} | {:error, String.t()} + def create_menu_item(params, user_id) do + Ecto.Multi.new() + |> Ecto.Multi.run(:get_menu_for_update, fn _repo, _changes -> + case Map.get(params, :parent_id) do + nil -> + # Early error handling as we need the parent_id before calling the MenuItem.create/1 + # which does the required fields validation + {:error, "Cannot create a menu item without providing parent_id"} + + parent_id -> + # Just check that the current user can update the parent menu + get_menu_for_update(parent_id, user_id) + end + end) + |> Ecto.Multi.run(:get_and_adjust_position, fn _repo, _changes -> + case Map.get(params, :position) do + nil -> + # If `position` is not specified, add it at the end by getting the last position + 1 + {:ok, get_next_position(params.parent_id)} + + position when is_integer(position) -> + # If `position` is specified, bump all the positions bigger than it by 1 in + # order to avoid having multiple items with the same position. + {:ok, {_, nil}} = inc_all_positions_after(params.parent_id, position) + + {:ok, position} + end + end) + |> Ecto.Multi.run( + :create_menu_item, + fn _repo, %{get_and_adjust_position: position} -> + params = params |> Map.merge(%{position: position, parent_id: params.parent_id}) + query = MenuItem.create(params) + Repo.insert(query) + end + ) + |> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{get_menu_for_update: menu} -> + get_menu(menu.id, user_id) + end) + |> Repo.transaction() + |> process_transaction_result(:get_menu_with_preloads) + end + + @doc ~s""" + Update an existing menu item. + + A menu item can have the follwing fields updated: + - position - change the position of the item in the menu + - parent_id - change the parent menu of the item. On the frontend this is done + by dragging and dropping the item in the menu tree (this can also update the position) + + The entity (query, dashboard, etc.) cannot be changed. Delete a menu item and insert a new + one instead. + """ + @spec update_menu_item(menu_item_id, update_menu_item_params, user_id) :: + {:ok, Menu.t()} | {:error, String.t()} + def update_menu_item(menu_item_id, params, user_id) do + Ecto.Multi.new() + |> Ecto.Multi.run(:get_menu_item_for_update, fn _repo, _changes -> + get_menu_item_for_update(menu_item_id, user_id) + end) + |> Ecto.Multi.run( + :maybe_update_items_positions, + fn _repo, %{get_menu_item_for_update: menu_item} -> + case Map.get(params, :position) do + nil -> + {:ok, nil} + + position when is_integer(position) -> + # If `position` is specified, bump all the positions bigger than it by 1 in + # order to avoid having multiple items with the same position. + {:ok, {_, nil}} = inc_all_positions_after(menu_item.parent_id, position) + {:ok, position} + end + end + ) + |> Ecto.Multi.run(:update_menu_item, fn _repo, %{get_menu_item_for_update: menu_item} -> + query = MenuItem.update(menu_item, params) + Repo.update(query) + end) + |> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{update_menu_item: menu_item} -> + get_menu(menu_item.parent_id, user_id) + end) + |> Repo.transaction() + |> process_transaction_result(:get_menu_with_preloads) + end + + @doc ~s""" + Delete a menu item. + """ + @spec delete_menu_item(menu_item_id, user_id) :: + {:ok, Menu.t()} | {:error, String.t()} + def delete_menu_item(menu_item_id, user_id) do + Ecto.Multi.new() + |> Ecto.Multi.run(:get_menu_item, fn _repo, _changes -> + get_menu_item_for_update(menu_item_id, user_id) + end) + |> Ecto.Multi.run(:delete_menu_item, fn _repo, %{get_menu_item: menu_item} -> + Repo.delete(menu_item) + end) + |> Ecto.Multi.run(:get_menu_with_preloads, fn _repo, %{delete_menu_item: menu_item} -> + get_menu(menu_item.parent_id, user_id) + end) + |> Repo.transaction() + |> process_transaction_result(:get_menu_with_preloads) + end + + # Private functions + + defp get_menu_for_update(menu_id, user_id) do + query = Menu.get_for_update(menu_id, user_id) + + case Repo.one(query) do + nil -> {:error, "Menu item does not exist"} + menu -> {:ok, menu} + end + end + + defp get_menu_item_for_update(menu_item_id, user_id) do + query = MenuItem.get_for_update(menu_item_id, user_id) + + case Repo.one(query) do + nil -> {:error, "Menu item does not exist"} + menu -> {:ok, menu} + end + end + + defp get_next_position(menu_id) do + query = MenuItem.get_next_position(menu_id) + {:ok, Repo.one(query)} + end + + defp inc_all_positions_after(menu_id, position) do + query = MenuItem.inc_all_positions_after(menu_id, position) + {:ok, Repo.update_all(query, [])} + end + + defp process_transaction_result({:ok, map}, ok_field), + do: {:ok, map[ok_field]} + + defp process_transaction_result({:error, _, %Ecto.Changeset{} = changeset, _}, _ok_field), + do: {:error, changeset_errors_string(changeset)} + + defp process_transaction_result({:error, _, error, _}, _ok_field), + do: {:error, error} + + # Helpers for transforming a menu struct to a simple map + defp recursively_order_menu_items(%{menu_items: menu_items} = map) do + sorted_menu_items = + Enum.sort_by(menu_items, & &1.position, :asc) + |> Enum.map(fn + %{menu_items: [_ | _]} = elem -> recursively_order_menu_items(elem) + x -> x + end) + + %{map | menu_items: sorted_menu_items} + end + + defp recursively_order_menu_items(data), do: data + + defp get_menu_items(%Menu{menu_items: []}), do: [] + + defp get_menu_items(%Menu{menu_items: list}) when is_list(list) do + list + |> Enum.map(fn + %{id: menu_item_id, query: %{id: _} = map, position: position} -> + Map.take(map, [:id, :name, :description]) + |> Map.merge(%{type: :query, position: position, menu_item_id: menu_item_id}) + + %{id: menu_item_id, dashboard: %{id: _} = map, position: position} -> + Map.take(map, [:id, :name, :description]) + |> Map.merge(%{type: :dashboard, position: position, menu_item_id: menu_item_id}) + + %{id: menu_item_id, menu: %{id: _} = map, position: position} -> + menu_to_simple_map(map) + |> Map.merge(%{type: :menu, position: position, menu_item_id: menu_item_id}) + end) + end +end diff --git a/lib/sanbase/menu/schema/menu.ex b/lib/sanbase/menu/schema/menu.ex new file mode 100644 index 0000000000..0026bf6e37 --- /dev/null +++ b/lib/sanbase/menu/schema/menu.ex @@ -0,0 +1,72 @@ +defmodule Sanbase.Menus.Menu do + use Ecto.Schema + + import Ecto.Query + import Ecto.Changeset + + alias Sanbase.Menus.MenuItem + alias Sanbase.Accounts.User + alias __MODULE__, as: Menu + + @type menu_id :: non_neg_integer() + + @type t :: %__MODULE__{} + + schema "menus" do + field(:name, :string) + field(:description, :string) + + has_many(:menu_items, MenuItem, foreign_key: :parent_id) + + # Indicate that if this menu is a sub-menu. + belongs_to(:parent, Menu) + belongs_to(:user, User) + + # The menus that do not belong to a user, but are created + # by an admin and are accsessible to everyone. + field(:is_global, :boolean, default: false) + + timestamps() + end + + def create(attrs \\ %{}) do + %Menu{} + |> cast(attrs, [:user_id, :name, :description, :parent_id]) + |> validate_required([:name, :user_id]) + |> validate_length(:name, min: 1, max: 256) + end + + def update(menu, attrs) do + menu + |> cast(attrs, [:name, :description, :parent_id]) + |> validate_length(:name, min: 1, max: 256) + end + + @doc ~s""" + Get a menu by its id. + + The menus are accsessible if the user is the owner or if the menu is global + """ + def by_id(id, querying_user_id) do + base_query() + |> where([m], m.id == ^id and (m.user_id == ^querying_user_id or m.is_global == true)) + |> preload([ + # Preload 2 levels of menu items + # The items of the root-menu + :menu_items, + # The items of the sub-menus one level deep + menu_items: [:menu, :query, :dashboard], + menu_items: [menu: :menu_items, menu: [menu_items: [:menu, :query, :dashboard]]] + ]) + end + + def get_for_update(id, querying_user_id) do + base_query() + |> where([m], m.id == ^id and m.user_id == ^querying_user_id) + |> lock("FOR UPDATE") + end + + defp base_query() do + __MODULE__ + end +end diff --git a/lib/sanbase/menu/schema/menu_item.ex b/lib/sanbase/menu/schema/menu_item.ex new file mode 100644 index 0000000000..35e13db063 --- /dev/null +++ b/lib/sanbase/menu/schema/menu_item.ex @@ -0,0 +1,88 @@ +defmodule Sanbase.Menus.MenuItem do + use Ecto.Schema + + import Ecto.Query + import Ecto.Changeset + + alias Sanbase.Menus.Menu + alias Sanbase.Queries.Query + alias Sanbase.Queries.Dashboard + + @type t :: %__MODULE__{ + parent_id: Menu.menu_id(), + position: integer(), + query_id: Query.query_id(), + dashboard_id: Dashboard.dashboard_id(), + menu_id: Menu.menu_id(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @timestamps_opts [type: :utc_datetime] + schema "menu_items" do + belongs_to(:parent, Menu) + + belongs_to(:query, Query) + belongs_to(:dashboard, Dashboard) + belongs_to(:menu, Menu) + + field(:position, :integer) + + timestamps() + end + + def get_for_update(id, user_id) do + base_query() + |> join(:left, [mi], p in Menu, on: mi.parent_id == p.id, as: :parent) + |> where([mi, parent: p], mi.id == ^id and p.user_id == ^user_id) + |> lock("FOR UPDATE") + end + + @doc ~s""" + Get the next position for a menu item inside a specific menu + (it can be either sub-menu or a root menu) + """ + def get_next_position(parent_menu_id) do + base_query() + |> where([m], m.parent_id == ^parent_menu_id) + |> select([m], coalesce(max(m.position), 0) + 1) + end + + @doc ~s""" + Get the next position for a menu item inside a specific menu + (it can be either sub-menu or a root menu) + """ + def inc_all_positions_after(parent_menu_id, position) do + base_query() + |> where([m], m.parent_id == ^parent_menu_id and m.position >= ^position) + |> update([m], inc: [position: +1]) + end + + def create(attrs \\ %{}) do + %__MODULE__{} + |> cast(attrs, [ + # Who this item belongs to + :parent_id, + # What is the item. There's check constraint on the DB level that only one + # of these can be set + :menu_id, + :query_id, + :dashboard_id, + # The position of the item in the menu + :position + ]) + |> validate_required([:parent_id, :position]) + end + + def update(menu, attrs) do + menu + # Do not allow to change the entity. Prefer deleting and adding a new item instead. + |> cast(attrs, [:parent_id, :position]) + end + + # Private functions + + defp base_query() do + from(m in __MODULE__) + end +end diff --git a/lib/sanbase/project/jobs/project_job.ex b/lib/sanbase/project/jobs/project_job.ex index e4994b72a1..98ae439d7f 100644 --- a/lib/sanbase/project/jobs/project_job.ex +++ b/lib/sanbase/project/jobs/project_job.ex @@ -19,12 +19,15 @@ defmodule Sanbase.Project.Job do Enum.map(projects, fn project -> {project, get_ecosystem_full_path(project, slug_to_project_map)} end) + |> Enum.reject(fn {p, _} -> is_nil(p) end) |> Enum.map(fn {project, []} -> {project, ""} {project, path} -> {project, "/" <> Enum.join(path, "/") <> "/"} end) end + defp get_ecosystem_full_path(nil, _slug_to_project_map), do: [] + defp get_ecosystem_full_path(project, slug_to_project_map) do case project.ecosystem == project.slug do true -> diff --git a/lib/sanbase/run_examples.ex b/lib/sanbase/run_examples.ex index f7fce5b0d3..6c1636616c 100644 --- a/lib/sanbase/run_examples.ex +++ b/lib/sanbase/run_examples.ex @@ -6,8 +6,12 @@ defmodule Sanbase.RunExamples do running. The quereies here **must** make a DB request in order to properly test the SQL. Do not run in tests, as if mocked, the purpose of this module would be lost. """ + + import Ecto.Query + @queries [ :santiment_queries, + :menus, :basic_metric_queries, :available_metrics, :trending_words, @@ -714,7 +718,6 @@ defmodule Sanbase.RunExamples do ) {:ok, dashboard} = Sanbase.Dashboards.create_dashboard(%{name: "MyName"}, user.id) - {:ok, mapping} = Sanbase.Dashboards.add_query_to_dashboard(dashboard.id, query.id, user.id) # Add and remove the mapping to test the removal @@ -737,6 +740,39 @@ defmodule Sanbase.RunExamples do user.id ) + query_id = query.id + dashboard_id = dashboard.id + dashboard_query_mapping_id = mapping.id + + %Sanbase.Queries.DashboardCache{ + dashboard_id: ^dashboard_id, + queries: %{ + ^dashboard_query_mapping_id => %{ + clickhouse_query_id: _, + column_types: ["String", "UInt64"], + columns: ["big_num", "num"], + dashboard_id: _, + dashboard_query_mapping_id: ^dashboard_query_mapping_id, + query_end_time: _, + query_id: ^query_id, + query_start_time: _, + rows: [["2.12 Trillion", 2_123_801_239_123]], + summary: %{ + "read_bytes" => 1.0, + "read_rows" => 1.0, + "result_bytes" => 0.0, + "result_rows" => 0.0, + "total_rows_to_read" => 0.0, + "written_bytes" => 0.0, + "written_rows" => 0.0 + }, + updated_at: _ + } + }, + inserted_at: _, + updated_at: _ + } = stored + {:ok, dashboard_cache} = Sanbase.Dashboards.get_cached_dashboard_queries_executions(dashboard.id, user.id) @@ -745,4 +781,93 @@ defmodule Sanbase.RunExamples do {:ok, :success} end + + defp do_run(:menus) do + user = Sanbase.Factory.insert(:user) + user2 = Sanbase.Factory.insert(:user) + + {:ok, query} = Sanbase.Queries.create_query(%{name: "Query"}, user.id) + {:ok, dashboard} = Sanbase.Dashboards.create_dashboard(%{name: "Dashboard"}, user.id) + + {:ok, menu} = + Sanbase.Menus.create_menu(%{name: "MyMenu", description: "MyDescription"}, user.id) + + {:ok, _} = + Sanbase.Menus.create_menu_item( + %{parent_id: menu.id, query_id: query.id, position: 1}, + user.id + ) + + {:ok, _} = + Sanbase.Menus.create_menu_item( + %{parent_id: menu.id, dashboard_id: dashboard.id, position: 2}, + user.id + ) + + # Cannot create item on non-owner menu + {:error, _} = + Sanbase.Menus.create_menu_item( + %{parent_id: menu.id, dashboard_id: dashboard.id, position: 2}, + user2.id + ) + + {:ok, sub_menu} = + Sanbase.Menus.create_menu( + %{ + name: "MySubMenu", + description: "MySubDescription", + parent_id: menu.id, + position: 1 + }, + user.id + ) + + {:ok, _} = Sanbase.Menus.update_menu(sub_menu.id, %{name: "MySubMenuNewName"}, user.id) + # Cannot update non-owner menu + {:error, _} = Sanbase.Menus.update_menu(sub_menu.id, %{name: "hehe"}, user2.id) + {:ok, fetched_menu} = Sanbase.Menus.get_menu(menu.id, user.id) + + menu_id = menu.id + sub_menu_id = sub_menu.id + query_id = query.id + dashboard_id = dashboard.id + + %{ + description: "MyDescription", + id: ^menu_id, + menu_items: [ + %{ + description: "MySubDescription", + id: ^sub_menu_id, + menu_items: [], + name: "MySubMenuNewName", + position: 1, + type: :menu + }, + %{description: nil, id: ^query_id, name: "Query", position: 2, type: :query}, + %{description: nil, id: ^dashboard_id, name: "Dashboard", position: 3, type: :dashboard} + ], + name: "MyMenu", + type: :menu + } = Sanbase.Menus.menu_to_simple_map(fetched_menu) + + for r <- [query, dashboard, sub_menu] do + Sanbase.Repo.delete(r) + end + + # Check that the menu still exists + {:ok, fetched_menu} = Sanbase.Menus.get_menu(menu.id, user.id) + # The menu does not have any menu items now + [] = fetched_menu.menu_items + + # Deleting the query, dashboard and sub_menu also cascaded and deleted the + # menu_items rows + menu_item_ids = from(mi in Sanbase.Menus.MenuItem, where: mi.parent_id == ^menu.id) + [] = Sanbase.Repo.all(menu_item_ids) + + Sanbase.Repo.delete(menu) + {:error, _} = Sanbase.Menus.get_menu(menu.id, user.id) + + {:ok, :success} + end end diff --git a/lib/sanbase_web/graphql/resolvers/menu_resolver.ex.ex b/lib/sanbase_web/graphql/resolvers/menu_resolver.ex.ex new file mode 100644 index 0000000000..1a2acd7ccb --- /dev/null +++ b/lib/sanbase_web/graphql/resolvers/menu_resolver.ex.ex @@ -0,0 +1,98 @@ +defmodule SanbaseWeb.Graphql.Resolvers.MenuResolver do + alias Sanbase.Menus + + # Menu CRUD + + def get_menu( + _root, + %{id: menu_id}, + resolution + ) do + querying_user_id = get_in(resolution.context.auth, [:current_user, Access.key(:id)]) + + case Menus.get_menu(menu_id, querying_user_id) do + {:ok, menu} -> {:ok, Menus.menu_to_simple_map(menu)} + {:error, reason} -> {:error, reason} + end + end + + def create_menu(_root, %{name: _} = param, %{context: %{auth: %{current_user: current_user}}}) do + case Menus.create_menu(param, current_user.id) do + {:ok, menu} -> {:ok, Menus.menu_to_simple_map(menu)} + {:error, reason} -> {:error, reason} + end + end + + def update_menu(_root, %{id: id} = params, %{context: %{auth: %{current_user: current_user}}}) do + case Menus.update_menu(id, params, current_user.id) do + {:ok, menu} -> {:ok, Menus.menu_to_simple_map(menu)} + {:error, reason} -> {:error, reason} + end + end + + def delete_menu(_root, %{id: id}, %{context: %{auth: %{current_user: current_user}}}) do + case Menus.delete_menu(id, current_user.id) do + {:ok, menu} -> {:ok, Menus.menu_to_simple_map(menu)} + {:error, reason} -> {:error, reason} + end + end + + # MenuItem C~R~UD + + def create_menu_item(_root, %{} = args, %{ + context: %{auth: %{current_user: current_user}} + }) do + with {:ok, params} <- create_menu_item_params(args) do + case Menus.create_menu_item(params, current_user.id) do + {:ok, menu} -> {:ok, Menus.menu_to_simple_map(menu)} + {:error, reason} -> {:error, reason} + end + end + end + + def update_menu_item(_root, %{id: id} = params, %{ + context: %{auth: %{current_user: current_user}} + }) do + case Menus.update_menu_item(id, params, current_user.id) do + {:ok, menu} -> {:ok, Menus.menu_to_simple_map(menu)} + {:error, reason} -> {:error, reason} + end + end + + def delete_menu_item(_root, %{id: id}, %{context: %{auth: %{current_user: current_user}}}) do + case Menus.delete_menu_item(id, current_user.id) do + {:ok, menu} -> {:ok, Menus.menu_to_simple_map(menu)} + {:error, reason} -> {:error, reason} + end + end + + # Private functions + + defp create_menu_item_params(%{parent_id: parent_id, entity: entity} = args) do + with {:ok, entity_params} <- entity_to_params(entity) do + params = + %{parent_id: parent_id} + |> Map.merge(entity_params) + |> Map.merge(Map.take(args, [:position])) + + {:ok, params} + end + end + + defp create_menu_item_params(_), + do: + {:error, + "Create menu item parameters are missing the required parent_menu_id and/or entity fields"} + + defp entity_to_params(map) do + params = Map.reject(map, fn {_k, v} -> is_nil(v) end) + + case map_size(map) do + 1 -> + {:ok, params} + + _ -> + {:error, "The entity field must contain exactly one key-value pair with non-null value"} + end + end +end diff --git a/lib/sanbase_web/graphql/schema.ex b/lib/sanbase_web/graphql/schema.ex index 1610a1b447..212a6ac77a 100644 --- a/lib/sanbase_web/graphql/schema.ex +++ b/lib/sanbase_web/graphql/schema.ex @@ -54,6 +54,7 @@ defmodule SanbaseWeb.Graphql.Schema do import_types(Graphql.IntercomTypes) import_types(Graphql.MarketSegmentTypes) import_types(Graphql.MarketTypes) + import_types(Graphql.MenuTypes) import_types(Graphql.MetricTypes) import_types(Graphql.MonitoredTwitterHandleTypes) import_types(Graphql.NftTypes) @@ -104,6 +105,7 @@ defmodule SanbaseWeb.Graphql.Schema do import_types(Graphql.Schema.LandingEmailsQueries) import_types(Graphql.Schema.LinkedUserQueries) import_types(Graphql.Schema.MarketQueries) + import_types(Graphql.Schema.MenuQueries) import_types(Graphql.Schema.MetricQueries) import_types(Graphql.Schema.NftQueries) import_types(Graphql.Schema.PresignedS3UrlQueries) @@ -166,6 +168,7 @@ defmodule SanbaseWeb.Graphql.Schema do import_fields(:linked_user_queries) import_fields(:market_queries) import_fields(:metric_queries) + import_fields(:menu_queries) import_fields(:monitored_twitter_handle_queries) import_fields(:nft_queries) import_fields(:presigned_s3_url_queries) @@ -206,6 +209,7 @@ defmodule SanbaseWeb.Graphql.Schema do import_fields(:intercom_mutations) import_fields(:landing_emails_mutations) import_fields(:linked_user_mutations) + import_fields(:menu_mutations) import_fields(:moderation_mutations) import_fields(:monitored_twitter_handle_mutations) import_fields(:project_chart_mutations) diff --git a/lib/sanbase_web/graphql/schema/queries/menu_queries.ex b/lib/sanbase_web/graphql/schema/queries/menu_queries.ex new file mode 100644 index 0000000000..27e2d25143 --- /dev/null +++ b/lib/sanbase_web/graphql/schema/queries/menu_queries.ex @@ -0,0 +1,94 @@ +defmodule SanbaseWeb.Graphql.Schema.MenuQueries do + @moduledoc ~s""" + Queries and mutations for working with short urls + """ + use Absinthe.Schema.Notation + + alias SanbaseWeb.Graphql.Resolvers.MenuResolver + alias SanbaseWeb.Graphql.Middlewares.JWTAuth + + object :menu_queries do + field :get_menu, :json do + meta(access: :free) + arg(:id, non_null(:integer)) + + middleware(JWTAuth) + + resolve(&MenuResolver.get_menu/3) + end + end + + object :menu_mutations do + field :create_menu, :json do + arg(:name, non_null(:string)) + arg(:description, :string) + + @desc ~s""" + If the menu is a sub-menu, this field should be set to the parent menu's id. + If this field is not set or explicitly set to null, the menu will be created as + a top-level menu + """ + arg(:parent_id, :integer, default_value: nil) + + @desc ~s""" + If :parent_id is provided, position is used to determine the position of the menu + is the parent menu. If not provided, it will be appended to the end of the list. + If a position is provided, all menu items with the same or bigger position in the same + menu will get their position increased by 1 in order to accomodate the new menu item. + """ + arg(:position, :integer, default_value: nil) + + middleware(JWTAuth) + + resolve(&MenuResolver.create_menu/3) + end + + field :update_menu, :json do + arg(:id, non_null(:integer)) + + arg(:name, :string) + arg(:description, :string) + arg(:parent_id, :integer) + + middleware(JWTAuth) + + resolve(&MenuResolver.update_menu/3) + end + + field :delete_menu, :json do + arg(:id, non_null(:integer)) + + middleware(JWTAuth) + + resolve(&MenuResolver.delete_menu/3) + end + + field :create_menu_item, :json do + arg(:parent_id, non_null(:integer)) + arg(:entity, non_null(:menu_item_entity)) + arg(:position, :integer) + + middleware(JWTAuth) + + resolve(&MenuResolver.create_menu_item/3) + end + + field :update_menu_item, :json do + arg(:id, non_null(:integer)) + arg(:position, :integer) + arg(:parent_id, :integer) + + middleware(JWTAuth) + + resolve(&MenuResolver.update_menu_item/3) + end + + field :delete_menu_item, :json do + arg(:id, non_null(:integer)) + + middleware(JWTAuth) + + resolve(&MenuResolver.delete_menu_item/3) + end + end +end diff --git a/lib/sanbase_web/graphql/schema/types/menu_types.ex b/lib/sanbase_web/graphql/schema/types/menu_types.ex new file mode 100644 index 0000000000..63d8cc2c04 --- /dev/null +++ b/lib/sanbase_web/graphql/schema/types/menu_types.ex @@ -0,0 +1,13 @@ +defmodule SanbaseWeb.Graphql.MenuTypes do + use Absinthe.Schema.Notation + + @desc ~s""" + A menu item is defined by the id of an existing entity. + Exactly one of the entities must be set. + """ + input_object :menu_item_entity do + field(:query_id, :integer) + field(:dashboard_id, :integer) + field(:menu_id, :integer) + end +end diff --git a/priv/repo/migrations/20231110093800_create_menus_table.exs b/priv/repo/migrations/20231110093800_create_menus_table.exs new file mode 100644 index 0000000000..234a56d741 --- /dev/null +++ b/priv/repo/migrations/20231110093800_create_menus_table.exs @@ -0,0 +1,42 @@ +defmodule Sanbase.Repo.Migrations.CreatesMenusTable do + use Ecto.Migration + + def change do + # Create menus table + create table(:menus) do + add(:name, :string) + add(:description, :string) + + add(:parent_id, references(:menus, on_delete: :delete_all)) + + add(:user_id, references(:users, on_delete: :delete_all)) + + add(:is_global, :boolean, default: false) + + timestamps() + end + + create(index(:menus, [:user_id])) + + # Create menu_items table + create table(:menu_items) do + add(:parent_id, references(:menus, on_delete: :delete_all)) + + add(:query_id, references(:queries, on_delete: :delete_all)) + add(:dashboard_id, references(:dashboards, on_delete: :delete_all)) + add(:menu_id, references(:menus, on_delete: :delete_all)) + + add(:position, :integer) + + timestamps() + end + + fk_check = """ + (CASE WHEN query_id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN dashboard_id IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN menu_id IS NULL THEN 0 ELSE 1 END) = 1 + """ + + create(constraint(:menu_items, :only_one_fk, check: fk_check)) + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 11db23dd05..3b7094a6fb 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2005,6 +2005,89 @@ CREATE SEQUENCE public.market_segments_id_seq ALTER SEQUENCE public.market_segments_id_seq OWNED BY public.market_segments.id; +-- +-- Name: menu_items; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.menu_items ( + id bigint NOT NULL, + parent_id bigint, + query_id bigint, + dashboard_id bigint, + menu_id bigint, + "position" integer, + inserted_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + CONSTRAINT only_one_fk CHECK (((( +CASE + WHEN (query_id IS NULL) THEN 0 + ELSE 1 +END + +CASE + WHEN (dashboard_id IS NULL) THEN 0 + ELSE 1 +END) + +CASE + WHEN (menu_id IS NULL) THEN 0 + ELSE 1 +END) = 1)) +); + + +-- +-- Name: menu_items_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.menu_items_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: menu_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.menu_items_id_seq OWNED BY public.menu_items.id; + + +-- +-- Name: menus; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.menus ( + id bigint NOT NULL, + name character varying(255), + description character varying(255), + parent_id bigint, + user_id bigint, + is_global boolean DEFAULT false, + inserted_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: menus_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.menus_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: menus_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.menus_id_seq OWNED BY public.menus.id; + + -- -- Name: metrics; Type: TABLE; Schema: public; Owner: - -- @@ -2049,6 +2132,8 @@ CREATE TABLE public.monitored_twitter_handles ( inserted_at timestamp without time zone NOT NULL, updated_at timestamp without time zone NOT NULL, status character varying(255) DEFAULT 'pending_approval'::character varying, + approved_by text, + declined_by text, comment text ); @@ -4753,6 +4838,20 @@ ALTER TABLE ONLY public.list_items ALTER COLUMN id SET DEFAULT nextval('public.l ALTER TABLE ONLY public.market_segments ALTER COLUMN id SET DEFAULT nextval('public.market_segments_id_seq'::regclass); +-- +-- Name: menu_items id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menu_items ALTER COLUMN id SET DEFAULT nextval('public.menu_items_id_seq'::regclass); + + +-- +-- Name: menus id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menus ALTER COLUMN id SET DEFAULT nextval('public.menus_id_seq'::regclass); + + -- -- Name: metrics id; Type: DEFAULT; Schema: public; Owner: - -- @@ -5588,6 +5687,22 @@ ALTER TABLE ONLY public.market_segments ADD CONSTRAINT market_segments_pkey PRIMARY KEY (id); +-- +-- Name: menu_items menu_items_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menu_items + ADD CONSTRAINT menu_items_pkey PRIMARY KEY (id); + + +-- +-- Name: menus menus_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menus + ADD CONSTRAINT menus_pkey PRIMARY KEY (id); + + -- -- Name: metrics metrics_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -6619,6 +6734,13 @@ CREATE UNIQUE INDEX list_items_user_list_id_project_id_index ON public.list_item CREATE UNIQUE INDEX market_segments_name_index ON public.market_segments USING btree (name); +-- +-- Name: menus_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX menus_user_id_index ON public.menus USING btree (user_id); + + -- -- Name: metrics_name_index; Type: INDEX; Schema: public; Owner: - -- @@ -7755,6 +7877,54 @@ ALTER TABLE ONLY public.list_items ADD CONSTRAINT list_items_user_list_id_fkey FOREIGN KEY (user_list_id) REFERENCES public.user_lists(id) ON DELETE CASCADE; +-- +-- Name: menu_items menu_items_dashboard_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menu_items + ADD CONSTRAINT menu_items_dashboard_id_fkey FOREIGN KEY (dashboard_id) REFERENCES public.dashboards(id) ON DELETE CASCADE; + + +-- +-- Name: menu_items menu_items_menu_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menu_items + ADD CONSTRAINT menu_items_menu_id_fkey FOREIGN KEY (menu_id) REFERENCES public.menus(id) ON DELETE CASCADE; + + +-- +-- Name: menu_items menu_items_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menu_items + ADD CONSTRAINT menu_items_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.menus(id) ON DELETE CASCADE; + + +-- +-- Name: menu_items menu_items_query_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menu_items + ADD CONSTRAINT menu_items_query_id_fkey FOREIGN KEY (query_id) REFERENCES public.queries(id) ON DELETE CASCADE; + + +-- +-- Name: menus menus_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menus + ADD CONSTRAINT menus_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.menus(id) ON DELETE CASCADE; + + +-- +-- Name: menus menus_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.menus + ADD CONSTRAINT menus_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- Name: monitored_twitter_handles monitored_twitter_handles_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -8925,4 +9095,6 @@ INSERT INTO public."schema_migrations" (version) VALUES (20231012130814); INSERT INTO public."schema_migrations" (version) VALUES (20231019111320); INSERT INTO public."schema_migrations" (version) VALUES (20231023123140); INSERT INTO public."schema_migrations" (version) VALUES (20231026084628); +INSERT INTO public."schema_migrations" (version) VALUES (20231030143950); INSERT INTO public."schema_migrations" (version) VALUES (20231101104145); +INSERT INTO public."schema_migrations" (version) VALUES (20231110093800); diff --git a/test/sanbase/billing/query_access_level_test.exs b/test/sanbase/billing/query_access_level_test.exs index 0eadb316ef..083a001480 100644 --- a/test/sanbase/billing/query_access_level_test.exs +++ b/test/sanbase/billing/query_access_level_test.exs @@ -93,6 +93,7 @@ defmodule Sanbase.Billing.QueryAccessLevelTest do :get_full_url, :get_label_based_metric_owners, :get_market_exchanges, + :get_menu, :get_metric, :get_most_recent, :get_most_used,