From 14260a4237f83407a12efb9be3866a2d4b06d34f Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 7 Oct 2025 11:17:57 +0200 Subject: [PATCH 1/4] feat: add api for membership management Add and delete users from a team --- .../controllers/api/team_controller.ex | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/logflare_web/controllers/api/team_controller.ex b/lib/logflare_web/controllers/api/team_controller.ex index 967b826498..011e3b3737 100644 --- a/lib/logflare_web/controllers/api/team_controller.ex +++ b/lib/logflare_web/controllers/api/team_controller.ex @@ -3,7 +3,8 @@ defmodule LogflareWeb.Api.TeamController do use OpenApiSpex.ControllerSpecs alias Logflare.Teams - + alias Logflare.TeamUsers + alias Logflare.Backends.Adaptor.BigQueryAdaptor alias LogflareWeb.OpenApi.Accepted alias LogflareWeb.OpenApi.Created alias LogflareWeb.OpenApi.List @@ -107,4 +108,62 @@ defmodule LogflareWeb.Api.TeamController do |> Plug.Conn.halt() end end + + operation(:add_member, + summary: "Add Team Member", + parameters: [ + token: [in: :path, description: "Team Token", type: :string], + id: [in: :path, description: "User ID as an email", type: :string] + ], + responses: %{ + 204 => Accepted.response(), + 404 => NotFound.response() + } + ) + + def add_member(%{assigns: %{user: user}} = conn, %{"token" => token, "id" => id}) do + auth_params = %{ + email: id + } + + with team when not is_nil(team) <- Teams.get_team_by(token: token, user_id: user.id), + {:ok, _} <- TeamUsers.insert_or_update_team_user(team, auth_params) do + BigQueryAdaptor.update_iam_policy() + BigQueryAdaptor.patch_dataset_access(team.user) + + conn + |> Plug.Conn.send_resp(204, []) + |> Plug.Conn.halt() + end + end + + operation(:delete_member, + summary: "Delete Team Member", + parameters: [ + token: [in: :path, description: "Team Token", type: :string], + id: [in: :path, description: "User ID as an email", type: :string] + ], + responses: %{ + 204 => Accepted.response(), + 404 => NotFound.response() + } + ) + + def delete_member(%{assigns: %{user: user}} = conn, %{"token" => token, "id" => id}) do + auth_params = %{ + email: id + } + + team_user = TeamUsers.get_team_user!(auth_params) + + with team when not is_nil(team) <- Teams.get_team_by(token: token, user_id: user.id), + {:ok, _} <- TeamUsers.delete_team_user(team_user) do + BigQueryAdaptor.update_iam_policy() + BigQueryAdaptor.patch_dataset_access(team.user) + + conn + |> Plug.Conn.send_resp(204, []) + |> Plug.Conn.halt() + end + end end From 6265cb710056f18fa6e3f73fff26b7d2017a753c Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 7 Oct 2025 17:08:02 +0200 Subject: [PATCH 2/4] chore: fix routes --- .../controllers/api/team_controller.ex | 38 ++++++++++++------- lib/logflare_web/router.ex | 5 ++- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/lib/logflare_web/controllers/api/team_controller.ex b/lib/logflare_web/controllers/api/team_controller.ex index 011e3b3737..b05edc1e81 100644 --- a/lib/logflare_web/controllers/api/team_controller.ex +++ b/lib/logflare_web/controllers/api/team_controller.ex @@ -4,6 +4,7 @@ defmodule LogflareWeb.Api.TeamController do alias Logflare.Teams alias Logflare.TeamUsers + alias Logflare.Users alias Logflare.Backends.Adaptor.BigQueryAdaptor alias LogflareWeb.OpenApi.Accepted alias LogflareWeb.OpenApi.Created @@ -11,7 +12,10 @@ defmodule LogflareWeb.Api.TeamController do alias LogflareWeb.OpenApi.NotFound alias LogflareWeb.OpenApi.UnprocessableEntity + require Logger + alias LogflareWeb.OpenApiSchemas.Team + alias LogflareWeb.OpenApiSchemas.TeamUser action_fallback(LogflareWeb.Api.FallbackController) @@ -112,21 +116,33 @@ defmodule LogflareWeb.Api.TeamController do operation(:add_member, summary: "Add Team Member", parameters: [ - token: [in: :path, description: "Team Token", type: :string], - id: [in: :path, description: "User ID as an email", type: :string] + token: [in: :path, description: "Team Token", type: :string] ], + request_body: TeamUser.params(), responses: %{ 204 => Accepted.response(), 404 => NotFound.response() } ) - def add_member(%{assigns: %{user: user}} = conn, %{"token" => token, "id" => id}) do + def add_member( + %{assigns: %{user: user}} = conn, + %{"team_token" => token, "email" => email} + ) do auth_params = %{ - email: id + email: email, + provider_uid: user.provider_uid, + provider: user.provider } - with team when not is_nil(team) <- Teams.get_team_by(token: token, user_id: user.id), + u = Users.get_by(email: email) + # user must exist or be created + if is_nil(u) do + Users.insert_user(auth_params) + Logger.info("Created new user #{email}") + end + + with team when not is_nil(team) <- Teams.get_team_by(token: token), {:ok, _} <- TeamUsers.insert_or_update_team_user(team, auth_params) do BigQueryAdaptor.update_iam_policy() BigQueryAdaptor.patch_dataset_access(team.user) @@ -137,8 +153,8 @@ defmodule LogflareWeb.Api.TeamController do end end - operation(:delete_member, - summary: "Delete Team Member", + operation(:remove_member, + summary: "Remove Team Member", parameters: [ token: [in: :path, description: "Team Token", type: :string], id: [in: :path, description: "User ID as an email", type: :string] @@ -149,12 +165,8 @@ defmodule LogflareWeb.Api.TeamController do } ) - def delete_member(%{assigns: %{user: user}} = conn, %{"token" => token, "id" => id}) do - auth_params = %{ - email: id - } - - team_user = TeamUsers.get_team_user!(auth_params) + def remove_member(%{assigns: %{user: user}} = conn, %{"team_token" => token, "id" => id}) do + team_user = TeamUsers.get_team_user_by(email: id) with team when not is_nil(team) <- Teams.get_team_by(token: token, user_id: user.id), {:ok, _} <- TeamUsers.delete_team_user(team_user) do diff --git a/lib/logflare_web/router.ex b/lib/logflare_web/router.ex index 59531c27c2..09e183c6f3 100644 --- a/lib/logflare_web/router.ex +++ b/lib/logflare_web/router.ex @@ -429,7 +429,10 @@ defmodule LogflareWeb.Router do resources("/teams", Api.TeamController, param: "token", only: [:index, :show, :create, :update, :delete] - ) + ) do + post "/members", Api.TeamController, :add_member + delete "/members/:id", Api.TeamController, :remove_member + end resources("/backends", Api.BackendController, param: "token", From 1a9f9830dc8c0935c61f70b4534473ac67e651f2 Mon Sep 17 00:00:00 2001 From: Adam Mokan Date: Tue, 7 Oct 2025 16:17:14 -0700 Subject: [PATCH 3/4] adds basic tests for additions to api `team_controller` and leverage pattern-matching to better handle with clause --- .../controllers/api/team_controller.ex | 72 ++++++++------ .../controllers/api/team_controller_test.exs | 98 +++++++++++++++++++ 2 files changed, 138 insertions(+), 32 deletions(-) diff --git a/lib/logflare_web/controllers/api/team_controller.ex b/lib/logflare_web/controllers/api/team_controller.ex index b05edc1e81..81a5533e92 100644 --- a/lib/logflare_web/controllers/api/team_controller.ex +++ b/lib/logflare_web/controllers/api/team_controller.ex @@ -3,7 +3,10 @@ defmodule LogflareWeb.Api.TeamController do use OpenApiSpex.ControllerSpecs alias Logflare.Teams + alias Logflare.Teams.Team alias Logflare.TeamUsers + alias Logflare.TeamUsers.TeamUser + alias Logflare.User alias Logflare.Users alias Logflare.Backends.Adaptor.BigQueryAdaptor alias LogflareWeb.OpenApi.Accepted @@ -14,8 +17,8 @@ defmodule LogflareWeb.Api.TeamController do require Logger - alias LogflareWeb.OpenApiSchemas.Team - alias LogflareWeb.OpenApiSchemas.TeamUser + alias LogflareWeb.OpenApiSchemas.Team, as: TeamSchema + alias LogflareWeb.OpenApiSchemas.TeamUser, as: TeamUserSchema action_fallback(LogflareWeb.Api.FallbackController) @@ -23,7 +26,7 @@ defmodule LogflareWeb.Api.TeamController do operation(:index, summary: "List teams", - responses: %{200 => List.response(Team)} + responses: %{200 => List.response(TeamSchema)} ) def index(%{assigns: %{user: user}} = conn, _) do @@ -35,13 +38,13 @@ defmodule LogflareWeb.Api.TeamController do summary: "Fetch team", parameters: [token: [in: :path, description: "Team Token", type: :string]], responses: %{ - 200 => Team.response(), + 200 => TeamSchema.response(), 404 => NotFound.response() } ) def show(%{assigns: %{user: user}} = conn, %{"token" => token}) do - with team when not is_nil(team) <- Teams.get_team_by_user_access(user, token), + with %Team{} = team <- Teams.get_team_by_user_access(user, token), team <- Teams.preload_fields(team, [:user, :team_users]) do json(conn, team) end @@ -49,9 +52,9 @@ defmodule LogflareWeb.Api.TeamController do operation(:create, summary: "Create Team", - request_body: Team.params(), + request_body: TeamSchema.params(), responses: %{ - 201 => Created.response(Team), + 201 => Created.response(TeamSchema), 404 => NotFound.response(), 422 => UnprocessableEntity.response() } @@ -69,9 +72,9 @@ defmodule LogflareWeb.Api.TeamController do operation(:update, summary: "Update team", parameters: [token: [in: :path, description: "Team Token", type: :string]], - request_body: Team.params(), + request_body: TeamSchema.params(), responses: %{ - 201 => Created.response(Team), + 201 => Created.response(TeamSchema), 204 => Accepted.response(), 404 => NotFound.response(), 422 => UnprocessableEntity.response() @@ -79,7 +82,7 @@ defmodule LogflareWeb.Api.TeamController do ) def update(%{assigns: %{user: user}} = conn, %{"token" => token} = params) do - with team when not is_nil(team) <- Teams.get_team_by(token: token, user_id: user.id), + with %Team{} = team <- Teams.get_team_by(token: token, user_id: user.id), {:ok, team} <- Teams.update_team(team, params), team <- Teams.preload_fields(team, [:user, :team_users]) do conn @@ -105,7 +108,7 @@ defmodule LogflareWeb.Api.TeamController do ) def delete(%{assigns: %{user: user}} = conn, %{"token" => token}) do - with team when not is_nil(team) <- Teams.get_team_by(token: token, user_id: user.id), + with %Team{} = team <- Teams.get_team_by(token: token, user_id: user.id), {:ok, _} <- Teams.delete_team(team) do conn |> Plug.Conn.send_resp(204, []) @@ -118,31 +121,36 @@ defmodule LogflareWeb.Api.TeamController do parameters: [ token: [in: :path, description: "Team Token", type: :string] ], - request_body: TeamUser.params(), + request_body: TeamUserSchema.params(), responses: %{ 204 => Accepted.response(), 404 => NotFound.response() } ) - def add_member( - %{assigns: %{user: user}} = conn, - %{"team_token" => token, "email" => email} - ) do - auth_params = %{ - email: email, - provider_uid: user.provider_uid, - provider: user.provider - } - - u = Users.get_by(email: email) - # user must exist or be created - if is_nil(u) do - Users.insert_user(auth_params) - Logger.info("Created new user #{email}") - end + def add_member(%{assigns: %{user: user}} = conn, %{"team_token" => token, "email" => email}) do + auth_params = + case Users.get_by(email: email) do + nil -> + {:ok, new_user} = Users.insert_user(%{email: email, provider: user.provider}) + Logger.info("Created new user #{email}") + + %{ + email: new_user.email, + provider_uid: new_user.provider_uid, + provider: new_user.provider + } + + %User{} = existing_user -> + %{ + email: existing_user.email, + provider_uid: existing_user.provider_uid, + provider: existing_user.provider + } + end - with team when not is_nil(team) <- Teams.get_team_by(token: token), + with %Team{} = team <- Teams.get_team_by(token: token, user_id: user.id), + team <- Teams.preload_user(team), {:ok, _} <- TeamUsers.insert_or_update_team_user(team, auth_params) do BigQueryAdaptor.update_iam_policy() BigQueryAdaptor.patch_dataset_access(team.user) @@ -166,9 +174,9 @@ defmodule LogflareWeb.Api.TeamController do ) def remove_member(%{assigns: %{user: user}} = conn, %{"team_token" => token, "id" => id}) do - team_user = TeamUsers.get_team_user_by(email: id) - - with team when not is_nil(team) <- Teams.get_team_by(token: token, user_id: user.id), + with %TeamUser{} = team_user <- TeamUsers.get_team_user_by(email: id), + %Team{} = team <- Teams.get_team_by(token: token, user_id: user.id), + team <- Teams.preload_user(team), {:ok, _} <- TeamUsers.delete_team_user(team_user) do BigQueryAdaptor.update_iam_policy() BigQueryAdaptor.patch_dataset_access(team.user) diff --git a/test/logflare_web/controllers/api/team_controller_test.exs b/test/logflare_web/controllers/api/team_controller_test.exs index 12ed2f779c..3eeb84e730 100644 --- a/test/logflare_web/controllers/api/team_controller_test.exs +++ b/test/logflare_web/controllers/api/team_controller_test.exs @@ -2,6 +2,8 @@ defmodule LogflareWeb.Api.TeamControllerTest do @moduledoc false use LogflareWeb.ConnCase + alias Logflare.TeamUsers + setup do insert(:plan) user = insert(:user) @@ -201,4 +203,100 @@ defmodule LogflareWeb.Api.TeamControllerTest do |> assert_schema("NotFoundResponse") == %{error: "Not Found"} end end + + describe "add_member/2" do + test "adds an existing user to a team", %{ + conn: conn, + user: user, + main_team: main_team + } do + new_member = insert(:user) + + assert conn + |> add_access_token(user, "private") + |> post(~p"/api/teams/#{main_team.token}/members", %{email: new_member.email}) + |> response(204) + |> assert_schema("AcceptedResponse") == "" + + team_users = TeamUsers.list_team_users_by(team_id: main_team.id) + + assert Enum.any?(team_users, fn tu -> + tu.email == String.downcase(new_member.email) + end) + end + + test "creates a new team member when adding with non-existent email", %{ + conn: conn, + user: user, + main_team: main_team + } do + new_email = "newuser@example.com" + + assert conn + |> add_access_token(user, "private") + |> post(~p"/api/teams/#{main_team.token}/members", %{email: new_email}) + |> response(204) + |> assert_schema("AcceptedResponse") == "" + + team_users = TeamUsers.list_team_users_by(team_id: main_team.id) + assert Enum.any?(team_users, fn tu -> tu.email == String.downcase(new_email) end) + end + + test "returns not found if doesn't own the team", %{conn: conn, main_team: main_team} do + invalid_user = insert(:user) + new_member = insert(:user) + + assert conn + |> add_access_token(invalid_user, "private") + |> post(~p"/api/teams/#{main_team.token}/members", %{email: new_member.email}) + |> json_response(404) + |> assert_schema("NotFoundResponse") == %{error: "Not Found"} + end + end + + describe "remove_member/2" do + test "removes a member from a team", %{ + conn: conn, + user: user, + main_team: main_team + } do + member_to_remove = insert(:user) + insert(:team_user, team: main_team, email: member_to_remove.email) + + assert conn + |> add_access_token(user, "private") + |> delete(~p"/api/teams/#{main_team.token}/members/#{member_to_remove.email}") + |> response(204) + |> assert_schema("AcceptedResponse") == "" + + team_users = TeamUsers.list_team_users_by(team_id: main_team.id) + refute Enum.any?(team_users, fn tu -> tu.email == member_to_remove.email end) + end + + test "returns not found if doesn't own the team", %{conn: conn, main_team: main_team} do + invalid_user = insert(:user) + member = insert(:user) + insert(:team_user, team: main_team, email: member.email) + + assert conn + |> add_access_token(invalid_user, "private") + |> delete(~p"/api/teams/#{main_team.token}/members/#{member.email}") + |> json_response(404) + |> assert_schema("NotFoundResponse") == %{error: "Not Found"} + end + + test "returns not found if team member doesn't exist", %{ + conn: conn, + user: user, + main_team: main_team + } do + non_existent_email = "nonexistent@example.com" + + assert conn + |> add_access_token(user, "private") + |> delete(~p"/api/teams/#{main_team.token}/members/#{non_existent_email}") + |> json_response(404) + |> assert_schema("NotFoundResponse") == %{error: "Not Found"} + end + end end From 8c8e78d10ee48dcb98029c4758a29863676f06c6 Mon Sep 17 00:00:00 2001 From: Adam Mokan Date: Tue, 7 Oct 2025 16:22:10 -0700 Subject: [PATCH 4/4] nit: reorder aliases and put `require` above them --- lib/logflare_web/controllers/api/team_controller.ex | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/logflare_web/controllers/api/team_controller.ex b/lib/logflare_web/controllers/api/team_controller.ex index 81a5533e92..2f586bb423 100644 --- a/lib/logflare_web/controllers/api/team_controller.ex +++ b/lib/logflare_web/controllers/api/team_controller.ex @@ -2,21 +2,20 @@ defmodule LogflareWeb.Api.TeamController do use LogflareWeb, :controller use OpenApiSpex.ControllerSpecs + require Logger + + alias Logflare.Backends.Adaptor.BigQueryAdaptor alias Logflare.Teams alias Logflare.Teams.Team alias Logflare.TeamUsers alias Logflare.TeamUsers.TeamUser alias Logflare.User alias Logflare.Users - alias Logflare.Backends.Adaptor.BigQueryAdaptor alias LogflareWeb.OpenApi.Accepted alias LogflareWeb.OpenApi.Created alias LogflareWeb.OpenApi.List alias LogflareWeb.OpenApi.NotFound alias LogflareWeb.OpenApi.UnprocessableEntity - - require Logger - alias LogflareWeb.OpenApiSchemas.Team, as: TeamSchema alias LogflareWeb.OpenApiSchemas.TeamUser, as: TeamUserSchema