Skip to content

Commit 8d9461d

Browse files
aerosolzoldar
andauthored
Guest Memberships via Sites API (#5205)
* List site guests via Sites API * Create guests via sites API * Delete guest memberships/invitations via Sites API * Credo * Test e-mail delivery * Format * Update extra/lib/plausible_web/controllers/api/external_sites_controller.ex Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com> * Update lib/plausible/sites.ex Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com> * Use aliases for optional where clauses * Swap order columns * Use GuestMembership.id in the union query * Prefer explicit enums over boolean status --------- Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
1 parent 1cfef2d commit 8d9461d

File tree

4 files changed

+424
-1
lines changed

4 files changed

+424
-1
lines changed

extra/lib/plausible_web/controllers/api/external_sites_controller.ex

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,30 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
2828
})
2929
end
3030

31+
def guests_index(conn, params) do
32+
user = conn.assigns.current_user
33+
34+
with {:ok, site_id} <- expect_param_key(params, "site_id"),
35+
{:ok, site} <- get_site(user, site_id, [:owner, :admin, :editor, :viewer]) do
36+
opts = [cursor_fields: [inserted_at: :desc, id: :desc], limit: 100, maximum_limit: 1000]
37+
page = site |> Sites.list_guests_query() |> paginate(params, opts)
38+
39+
json(conn, %{
40+
guests:
41+
Enum.map(page.entries, fn entry ->
42+
Map.take(entry, [:email, :role, :status])
43+
end),
44+
meta: pagination_meta(page.metadata)
45+
})
46+
else
47+
{:missing, "site_id"} ->
48+
H.bad_request(conn, "Parameter `site_id` is required to list goals")
49+
50+
{:error, :site_not_found} ->
51+
H.not_found(conn, "Site could not be found")
52+
end
53+
end
54+
3155
def goals_index(conn, params) do
3256
user = conn.assigns.current_user
3357

@@ -138,6 +162,81 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
138162
end
139163
end
140164

165+
def find_or_create_guest(conn, params) do
166+
with {:ok, site_id} <- expect_param_key(params, "site_id"),
167+
{:ok, email} <- expect_param_key(params, "email"),
168+
{:ok, role} <- expect_param_key(params, "role", ["viewer", "editor"]),
169+
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]) do
170+
existing = Repo.one(Sites.list_guests_query(site, email: email))
171+
172+
if existing do
173+
json(conn, %{
174+
role: existing.role,
175+
email: existing.email,
176+
status: existing.status
177+
})
178+
else
179+
case Plausible.Site.Memberships.CreateInvitation.create_invitation(
180+
site,
181+
conn.assigns.current_user,
182+
email,
183+
role
184+
) do
185+
{:ok, invitation} ->
186+
json(conn, %{
187+
role: invitation.role,
188+
email: invitation.team_invitation.email,
189+
status: "invited"
190+
})
191+
end
192+
end
193+
else
194+
{:error, :site_not_found} ->
195+
H.not_found(conn, "Site could not be found")
196+
197+
{:missing, "role"} ->
198+
H.bad_request(
199+
conn,
200+
"Parameter `role` is required to create guest. Possible values: `viewer` or `editor`"
201+
)
202+
203+
{:missing, param} ->
204+
H.bad_request(conn, "Parameter `#{param}` is required to create guest")
205+
end
206+
end
207+
208+
def delete_guest(conn, params) do
209+
with {:ok, site_id} <- expect_param_key(params, "site_id"),
210+
{:ok, email} <- expect_param_key(params, "email"),
211+
{:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]) do
212+
existing = Repo.one(Sites.list_guests_query(site, email: email))
213+
214+
case existing do
215+
%{status: "invited", id: id} ->
216+
with guest_invitation when not is_nil(guest_invitation) <-
217+
Repo.get(Teams.GuestInvitation, id) do
218+
Teams.Invitations.remove_guest_invitation(guest_invitation)
219+
end
220+
221+
%{status: "accepted", email: email} ->
222+
with %{} = user <- Repo.get_by(Plausible.Auth.User, email: email) do
223+
Teams.Memberships.remove(site, user)
224+
end
225+
226+
_ ->
227+
:ignore
228+
end
229+
230+
json(conn, %{"deleted" => true})
231+
else
232+
{:error, :site_not_found} ->
233+
H.not_found(conn, "Site could not be found")
234+
235+
{:missing, param} ->
236+
H.bad_request(conn, "Parameter `#{param}` is required to delete a guest")
237+
end
238+
end
239+
141240
def find_or_create_shared_link(conn, params) do
142241
with {:ok, site_id} <- expect_param_key(params, "site_id"),
143242
{:ok, link_name} <- expect_param_key(params, "name"),
@@ -235,10 +334,22 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
235334
%{"error" => error_msg}
236335
end
237336

238-
defp expect_param_key(params, key) do
337+
defp expect_param_key(params, key, inclusion \\ [])
338+
339+
defp expect_param_key(params, key, []) do
239340
case Map.fetch(params, key) do
240341
:error -> {:missing, key}
241342
res -> res
242343
end
243344
end
345+
346+
defp expect_param_key(params, key, inclusion) do
347+
case expect_param_key(params, key, []) do
348+
{:ok, value} = ok ->
349+
if value in inclusion, do: ok, else: {:missing, key}
350+
351+
_ ->
352+
{:missing, key}
353+
end
354+
end
244355
end

lib/plausible/sites.ex

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,67 @@ defmodule Plausible.Sites do
151151
%{memberships: memberships, invitations: site_transfers ++ invitations}
152152
end
153153

154+
@spec list_guests_query(Site.t(), Keyword.t()) :: Ecto.Query.t()
155+
def list_guests_query(site, opts \\ []) do
156+
guest_memberships =
157+
from(
158+
gm in Teams.GuestMembership,
159+
inner_join: tm in assoc(gm, :team_membership),
160+
inner_join: u in assoc(tm, :user),
161+
as: :user,
162+
where: gm.site_id == ^site.id,
163+
select: %{
164+
id: gm.id,
165+
inserted_at: gm.inserted_at,
166+
email: u.email,
167+
role: gm.role,
168+
status: "accepted"
169+
}
170+
)
171+
172+
guest_memberships =
173+
if email = opts[:email] do
174+
guest_memberships |> where([user: u], u.email == ^email)
175+
else
176+
guest_memberships
177+
end
178+
179+
guest_invitations =
180+
from(
181+
gi in Teams.GuestInvitation,
182+
inner_join: ti in assoc(gi, :team_invitation),
183+
as: :team_invitation,
184+
where: gi.site_id == ^site.id,
185+
select: %{
186+
id: gi.id,
187+
inserted_at: gi.inserted_at,
188+
email: ti.email,
189+
role: gi.role,
190+
status: "invited"
191+
}
192+
)
193+
194+
guest_invitations =
195+
if email = opts[:email] do
196+
guest_invitations |> where([team_invitation: ti], ti.email == ^email)
197+
else
198+
guest_invitations
199+
end
200+
201+
guests = union_all(guest_memberships, ^guest_invitations)
202+
203+
from(g in subquery(guests),
204+
select: %{
205+
id: g.id,
206+
inserted_at: g.inserted_at,
207+
email: g.email,
208+
role: g.role,
209+
status: g.status
210+
},
211+
order_by: [desc: g.inserted_at, desc: g.id]
212+
)
213+
end
214+
154215
@spec for_user_query(Auth.User.t(), Teams.Team.t() | nil) :: Ecto.Query.t()
155216
def for_user_query(user, team \\ nil) do
156217
query =

lib/plausible_web/router.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ defmodule PlausibleWeb.Router do
267267

268268
get "/", ExternalSitesController, :index
269269
get "/goals", ExternalSitesController, :goals_index
270+
get "/guests", ExternalSitesController, :guests_index
270271
get "/:site_id", ExternalSitesController, :get_site
271272
end
272273

@@ -275,8 +276,13 @@ defmodule PlausibleWeb.Router do
275276

276277
post "/", ExternalSitesController, :create_site
277278
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
279+
278280
put "/goals", ExternalSitesController, :find_or_create_goal
279281
delete "/goals/:goal_id", ExternalSitesController, :delete_goal
282+
283+
put "/guests", ExternalSitesController, :find_or_create_guest
284+
delete "/guests/:email", ExternalSitesController, :delete_guest
285+
280286
put "/:site_id", ExternalSitesController, :update_site
281287
delete "/:site_id", ExternalSitesController, :delete_site
282288
end

0 commit comments

Comments
 (0)