mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +03:00
Plugins API: Get/Create Funnels (#4192)
* Bootstrap OpenAPI Funnel schemas * Implement Plugins API Funnel view * Allow casting funnel step directly from `%Goal{}` * Check feature availability on funnel creation just like it's done when inserting goals * Implement Plugins API context module for Funnels * Implement GET/PUT funnels via Plugins API * Fix typo * A rare event in which dialyzer found an actual bug, wow! * Format * Wrap creation request with a root `funnel` key * Format * Extract common funnel get query * Remove redundant tag * Refactor queries a bit
This commit is contained in:
parent
62cd18e5e7
commit
823f7a8800
@ -10,7 +10,7 @@ config :plausible, Plausible.Repo, pool: Ecto.Adapters.SQL.Sandbox
|
|||||||
|
|
||||||
config :plausible, Plausible.ClickhouseRepo,
|
config :plausible, Plausible.ClickhouseRepo,
|
||||||
loggers: [Ecto.LogEntry],
|
loggers: [Ecto.LogEntry],
|
||||||
pool_size: 5
|
pool_size: 15
|
||||||
|
|
||||||
config :plausible, Plausible.Mailer, adapter: Bamboo.TestAdapter
|
config :plausible, Plausible.Mailer, adapter: Bamboo.TestAdapter
|
||||||
|
|
||||||
|
@ -15,7 +15,13 @@ defmodule Plausible.Funnel.Step do
|
|||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
|
|
||||||
def changeset(step, attrs \\ %{}) do
|
def changeset(step, goal_or_attrs \\ %{})
|
||||||
|
|
||||||
|
def changeset(step, %Plausible.Goal{id: goal_id}) do
|
||||||
|
changeset(step, %{goal_id: goal_id})
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(step, attrs) do
|
||||||
step
|
step
|
||||||
|> cast(attrs, [:goal_id])
|
|> cast(attrs, [:goal_id])
|
||||||
|> cast_assoc(:goal)
|
|> cast_assoc(:goal)
|
||||||
|
@ -15,12 +15,20 @@ defmodule Plausible.Funnels do
|
|||||||
|
|
||||||
@spec create(Plausible.Site.t(), String.t(), [map()]) ::
|
@spec create(Plausible.Site.t(), String.t(), [map()]) ::
|
||||||
{:ok, Funnel.t()}
|
{:ok, Funnel.t()}
|
||||||
| {:error, Ecto.Changeset.t() | :invalid_funnel_size}
|
| {:error, Ecto.Changeset.t() | :invalid_funnel_size | :upgrade_required}
|
||||||
def create(site, name, steps)
|
def create(site, name, steps)
|
||||||
when is_list(steps) and length(steps) in Funnel.min_steps()..Funnel.max_steps() do
|
when is_list(steps) and length(steps) in Funnel.min_steps()..Funnel.max_steps() do
|
||||||
site
|
site = Plausible.Repo.preload(site, :owner)
|
||||||
|> create_changeset(name, steps)
|
|
||||||
|> Repo.insert()
|
case Plausible.Billing.Feature.Funnels.check_availability(site.owner) do
|
||||||
|
{:error, _} = error ->
|
||||||
|
error
|
||||||
|
|
||||||
|
:ok ->
|
||||||
|
site
|
||||||
|
|> create_changeset(name, steps)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create(_site, _name, _goals) do
|
def create(_site, _name, _goals) do
|
||||||
@ -43,16 +51,17 @@ defmodule Plausible.Funnels do
|
|||||||
@spec list(Plausible.Site.t()) :: [
|
@spec list(Plausible.Site.t()) :: [
|
||||||
%{name: String.t(), id: pos_integer(), steps_count: pos_integer()}
|
%{name: String.t(), id: pos_integer(), steps_count: pos_integer()}
|
||||||
]
|
]
|
||||||
def list(%Plausible.Site{id: site_id}) do
|
def list(%Plausible.Site{} = site) do
|
||||||
Repo.all(
|
q =
|
||||||
from(f in Funnel,
|
from(f in Funnel,
|
||||||
inner_join: steps in assoc(f, :steps),
|
inner_join: steps in assoc(f, :steps),
|
||||||
where: f.site_id == ^site_id,
|
where: f.site_id == ^site.id,
|
||||||
select: %{name: f.name, id: f.id, steps_count: count(steps)},
|
|
||||||
group_by: f.id,
|
group_by: f.id,
|
||||||
order_by: [desc: :id]
|
order_by: [desc: :id],
|
||||||
|
select: %{name: f.name, id: f.id, steps_count: count(steps)}
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
Repo.all(q)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec delete(Plausible.Site.t() | pos_integer(), pos_integer()) :: :ok
|
@spec delete(Plausible.Site.t() | pos_integer(), pos_integer()) :: :ok
|
||||||
@ -71,25 +80,40 @@ defmodule Plausible.Funnels do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get(Plausible.Site.t() | pos_integer(), pos_integer()) ::
|
@spec get(Plausible.Site.t() | pos_integer(), pos_integer() | String.t()) ::
|
||||||
Funnel.t() | nil
|
Funnel.t() | nil
|
||||||
def get(%Plausible.Site{id: site_id}, by) do
|
def get(%Plausible.Site{id: site_id}, by) do
|
||||||
get(site_id, by)
|
get(site_id, by)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(site_id, funnel_id) when is_integer(site_id) and is_integer(funnel_id) do
|
def get(site_id, funnel_name) when is_integer(site_id) and is_binary(funnel_name) do
|
||||||
q =
|
site_id |> base_get_query() |> where([f], f.name == ^funnel_name) |> Repo.one()
|
||||||
from(f in Funnel,
|
end
|
||||||
where: f.site_id == ^site_id,
|
|
||||||
where: f.id == ^funnel_id,
|
|
||||||
inner_join: steps in assoc(f, :steps),
|
|
||||||
inner_join: goal in assoc(steps, :goal),
|
|
||||||
order_by: steps.step_order,
|
|
||||||
preload: [
|
|
||||||
steps: {steps, goal: goal}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Repo.one(q)
|
def get(site_id, funnel_id) when is_integer(site_id) and is_integer(funnel_id) do
|
||||||
|
site_id |> base_get_query() |> where([f], f.id == ^funnel_id) |> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec with_goals_query(Plausible.Site.t()) :: Ecto.Query.t()
|
||||||
|
def with_goals_query(site) do
|
||||||
|
from(f in Funnel,
|
||||||
|
inner_join: steps in assoc(f, :steps),
|
||||||
|
where: f.site_id == ^site.id,
|
||||||
|
group_by: f.id,
|
||||||
|
order_by: [desc: :id],
|
||||||
|
preload: [steps: :goal]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp base_get_query(site_id) do
|
||||||
|
from(f in Funnel,
|
||||||
|
where: f.site_id == ^site_id,
|
||||||
|
inner_join: steps in assoc(f, :steps),
|
||||||
|
inner_join: goal in assoc(steps, :goal),
|
||||||
|
order_by: steps.step_order,
|
||||||
|
preload: [
|
||||||
|
steps: {steps, goal: goal}
|
||||||
|
]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
61
extra/lib/plausible/plugins/api/funnels.ex
Normal file
61
extra/lib/plausible/plugins/api/funnels.ex
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
defmodule Plausible.Plugins.API.Funnels do
|
||||||
|
@moduledoc """
|
||||||
|
Plugins API context module for Funnels.
|
||||||
|
All high level Funnel operations should be implemented here.
|
||||||
|
"""
|
||||||
|
use Plausible
|
||||||
|
|
||||||
|
import Plausible.Pagination
|
||||||
|
|
||||||
|
alias Plausible.Repo
|
||||||
|
alias PlausibleWeb.Plugins.API.Schemas.Funnel.CreateRequest
|
||||||
|
|
||||||
|
@type create_request() :: CreateRequest.t()
|
||||||
|
|
||||||
|
@spec create(
|
||||||
|
Plausible.Site.t(),
|
||||||
|
create_request()
|
||||||
|
) ::
|
||||||
|
{:ok, Plausible.Funnel.t()}
|
||||||
|
| {:error, Ecto.Changeset.t()}
|
||||||
|
| {:error, :upgrade_required}
|
||||||
|
def create(site, create_request) do
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
with {:ok, goals} <- Plausible.Plugins.API.Goals.create(site, create_request.funnel.steps),
|
||||||
|
{:ok, funnel} <- get_or_create(site, create_request.funnel.name, goals) do
|
||||||
|
funnel
|
||||||
|
else
|
||||||
|
{:error, error} ->
|
||||||
|
Repo.rollback(error)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_or_create(site, name, goals) do
|
||||||
|
case get(site, name) do
|
||||||
|
%Plausible.Funnel{} = funnel ->
|
||||||
|
{:ok, funnel}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
case Plausible.Funnels.create(site, name, goals) do
|
||||||
|
{:ok, funnel} ->
|
||||||
|
# reload result with steps included
|
||||||
|
{:ok, get(site, funnel.id)}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_funnels(Plausible.Site.t(), map()) :: {:ok, Paginator.Page.t()}
|
||||||
|
def get_funnels(site, params) do
|
||||||
|
query = Plausible.Funnels.with_goals_query(site)
|
||||||
|
{:ok, paginate(query, params, cursor_fields: [{:id, :desc}])}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get(Plausible.Site.t(), pos_integer() | String.t()) :: nil | Plausible.Funnel.t()
|
||||||
|
def get(site, by) when is_integer(by) or is_binary(by) do
|
||||||
|
Plausible.Funnels.get(site, by)
|
||||||
|
end
|
||||||
|
end
|
120
extra/lib/plausible_web/plugins/api/controllers/funnels.ex
Normal file
120
extra/lib/plausible_web/plugins/api/controllers/funnels.ex
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
defmodule PlausibleWeb.Plugins.API.Controllers.Funnels do
|
||||||
|
@moduledoc """
|
||||||
|
Controller for the Funnel resource under Plugins API
|
||||||
|
"""
|
||||||
|
use PlausibleWeb, :plugins_api_controller
|
||||||
|
|
||||||
|
operation(:create,
|
||||||
|
id: "Funnel.GetOrCreate",
|
||||||
|
summary: "Get or create Funnel",
|
||||||
|
request_body: {"Funnel params", "application/json", Schemas.Funnel.CreateRequest},
|
||||||
|
responses: %{
|
||||||
|
created: {"Funnel", "application/json", Schemas.Funnel},
|
||||||
|
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized},
|
||||||
|
payment_required: {"Payment required", "application/json", Schemas.PaymentRequired},
|
||||||
|
unprocessable_entity:
|
||||||
|
{"Unprocessable entity", "application/json", Schemas.UnprocessableEntity}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(
|
||||||
|
%{private: %{open_api_spex: %{body_params: body_params}}} = conn,
|
||||||
|
_params
|
||||||
|
) do
|
||||||
|
site = conn.assigns.authorized_site
|
||||||
|
|
||||||
|
case Plausible.Plugins.API.Funnels.create(site, body_params) do
|
||||||
|
{:ok, funnel} ->
|
||||||
|
headers = [{"location", plugins_api_funnels_url(conn, :get, funnel.id)}]
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> prepend_resp_headers(headers)
|
||||||
|
|> put_view(Views.Funnel)
|
||||||
|
|> put_status(:created)
|
||||||
|
|> render("funnel.json", funnel: funnel, authorized_site: site)
|
||||||
|
|
||||||
|
{:error, :upgrade_required} ->
|
||||||
|
payment_required(conn)
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
Errors.error(conn, 422, changeset)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
operation(:index,
|
||||||
|
summary: "Retrieve Funnels",
|
||||||
|
parameters: [
|
||||||
|
limit: [in: :query, type: :integer, description: "Maximum entries per page", example: 10],
|
||||||
|
after: [
|
||||||
|
in: :query,
|
||||||
|
type: :string,
|
||||||
|
description: "Cursor value to seek after - generated internally"
|
||||||
|
],
|
||||||
|
before: [
|
||||||
|
in: :query,
|
||||||
|
type: :string,
|
||||||
|
description: "Cursor value to seek before - generated internally"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
responses: %{
|
||||||
|
ok: {"Funnels response", "application/json", Schemas.Funnel.ListResponse},
|
||||||
|
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@spec index(Plug.Conn.t(), %{}) :: Plug.Conn.t()
|
||||||
|
def index(conn, _params) do
|
||||||
|
{:ok, pagination} = API.Funnels.get_funnels(conn.assigns.authorized_site, conn.query_params)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_view(Views.Funnel)
|
||||||
|
|> render("index.json", %{pagination: pagination})
|
||||||
|
end
|
||||||
|
|
||||||
|
operation(:get,
|
||||||
|
summary: "Retrieve Funnel by ID",
|
||||||
|
parameters: [
|
||||||
|
id: [
|
||||||
|
in: :path,
|
||||||
|
type: :integer,
|
||||||
|
description: "Funnel ID",
|
||||||
|
example: 123,
|
||||||
|
required: true
|
||||||
|
]
|
||||||
|
],
|
||||||
|
responses: %{
|
||||||
|
ok: {"Goal", "application/json", Schemas.Funnel},
|
||||||
|
not_found: {"NotFound", "application/json", Schemas.NotFound},
|
||||||
|
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized},
|
||||||
|
unprocessable_entity:
|
||||||
|
{"Unprocessable entity", "application/json", Schemas.UnprocessableEntity}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@spec get(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||||
|
def get(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _params) do
|
||||||
|
site = conn.assigns.authorized_site
|
||||||
|
|
||||||
|
case API.Funnels.get(site, id) do
|
||||||
|
nil ->
|
||||||
|
conn
|
||||||
|
|> put_view(Views.Error)
|
||||||
|
|> put_status(:not_found)
|
||||||
|
|> render("404.json")
|
||||||
|
|
||||||
|
funnel ->
|
||||||
|
conn
|
||||||
|
|> put_view(Views.Funnel)
|
||||||
|
|> put_status(:ok)
|
||||||
|
|> render("funnel.json", funnel: funnel, authorized_site: site)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp payment_required(conn) do
|
||||||
|
Errors.error(
|
||||||
|
conn,
|
||||||
|
402,
|
||||||
|
"#{Plausible.Billing.Feature.Funnels.display_name()} is part of the Plausible Business plan. To get access to this feature, please upgrade your account."
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
55
extra/lib/plausible_web/plugins/api/schemas/funnel.ex
Normal file
55
extra/lib/plausible_web/plugins/api/schemas/funnel.ex
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
defmodule PlausibleWeb.Plugins.API.Schemas.Funnel do
|
||||||
|
@moduledoc """
|
||||||
|
OpenAPI schema for Funnel
|
||||||
|
"""
|
||||||
|
use Plausible.Funnel.Const
|
||||||
|
use PlausibleWeb, :open_api_schema
|
||||||
|
|
||||||
|
OpenApiSpex.schema(%{
|
||||||
|
title: "Funnel",
|
||||||
|
description: "Funnel object",
|
||||||
|
type: :object,
|
||||||
|
properties: %{
|
||||||
|
funnel: %Schema{
|
||||||
|
type: :object,
|
||||||
|
required: [:id, :name, :steps],
|
||||||
|
properties: %{
|
||||||
|
id: %Schema{type: :integer, description: "Funnel ID"},
|
||||||
|
name: %Schema{type: :string, description: "Name"},
|
||||||
|
steps: %Schema{
|
||||||
|
description: "Funnel Steps",
|
||||||
|
type: :array,
|
||||||
|
minItems: 2,
|
||||||
|
maxItems: Funnel.Const.max_steps(),
|
||||||
|
items: Schemas.Goal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
example: %{
|
||||||
|
funnel: %{
|
||||||
|
id: 123,
|
||||||
|
name: "My Marketing Funnel",
|
||||||
|
steps: [
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Pageview",
|
||||||
|
goal: %{
|
||||||
|
id: 1,
|
||||||
|
display_name: "Visit /product/1",
|
||||||
|
path: "/product/1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Revenue",
|
||||||
|
goal: %{
|
||||||
|
id: 2,
|
||||||
|
display_name: "Purchase",
|
||||||
|
currency: "EUR",
|
||||||
|
event_name: "Purchase"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
@ -0,0 +1,56 @@
|
|||||||
|
defmodule PlausibleWeb.Plugins.API.Schemas.Funnel.CreateRequest do
|
||||||
|
@moduledoc """
|
||||||
|
OpenAPI schema for Funnel creation request - get or creates goals along the way
|
||||||
|
"""
|
||||||
|
use Plausible.Funnel.Const
|
||||||
|
use PlausibleWeb, :open_api_schema
|
||||||
|
|
||||||
|
OpenApiSpex.schema(%{
|
||||||
|
title: "Funnel.CreateRequest",
|
||||||
|
description: "Funnel creation params",
|
||||||
|
type: :object,
|
||||||
|
required: [:funnel],
|
||||||
|
properties: %{
|
||||||
|
funnel: %Schema{
|
||||||
|
type: :object,
|
||||||
|
required: [:steps, :name],
|
||||||
|
properties: %{
|
||||||
|
steps: %Schema{
|
||||||
|
description: "Funnel Steps",
|
||||||
|
type: :array,
|
||||||
|
minItems: 2,
|
||||||
|
maxItems: Funnel.Const.max_steps(),
|
||||||
|
items: %Schema{
|
||||||
|
oneOf: [
|
||||||
|
Schemas.Goal.CreateRequest.CustomEvent,
|
||||||
|
Schemas.Goal.CreateRequest.Revenue,
|
||||||
|
Schemas.Goal.CreateRequest.Pageview
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
name: %Schema{type: :string, description: "Funnel Name"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
example: %{
|
||||||
|
funnel: %{
|
||||||
|
name: "My First Funnel",
|
||||||
|
steps: [
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Pageview",
|
||||||
|
goal: %{
|
||||||
|
path: "/product/123"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Revenue",
|
||||||
|
goal: %{
|
||||||
|
currency: "EUR",
|
||||||
|
event_name: "Purchase"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
@ -0,0 +1,26 @@
|
|||||||
|
defmodule PlausibleWeb.Plugins.API.Schemas.Funnel.ListResponse do
|
||||||
|
@moduledoc """
|
||||||
|
OpenAPI schema for Funnel list response
|
||||||
|
"""
|
||||||
|
use PlausibleWeb, :open_api_schema
|
||||||
|
|
||||||
|
OpenApiSpex.schema(%{
|
||||||
|
title: "Funnel.ListResponse",
|
||||||
|
description: "Funnels list response",
|
||||||
|
type: :object,
|
||||||
|
required: [:funnels, :meta],
|
||||||
|
properties: %{
|
||||||
|
funnels: %Schema{
|
||||||
|
items: Schemas.Funnel,
|
||||||
|
type: :array
|
||||||
|
},
|
||||||
|
meta: %Schema{
|
||||||
|
required: [:pagination],
|
||||||
|
type: :object,
|
||||||
|
properties: %{
|
||||||
|
pagination: Schemas.PaginationMetadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
50
extra/lib/plausible_web/plugins/api/views/funnel.ex
Normal file
50
extra/lib/plausible_web/plugins/api/views/funnel.ex
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
defmodule PlausibleWeb.Plugins.API.Views.Funnel do
|
||||||
|
@moduledoc """
|
||||||
|
View for rendering Funnels in the Plugins API
|
||||||
|
"""
|
||||||
|
|
||||||
|
use PlausibleWeb, :plugins_api_view
|
||||||
|
|
||||||
|
def render("index.json", %{
|
||||||
|
pagination: %{entries: funnels, metadata: metadata},
|
||||||
|
authorized_site: site,
|
||||||
|
conn: conn
|
||||||
|
}) do
|
||||||
|
%{
|
||||||
|
funnels: render_many(funnels, __MODULE__, "funnel.json", authorized_site: site),
|
||||||
|
meta: render_metadata_links(metadata, :plugins_api_funnels_url, :index, conn.query_params)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render("index.json", %{
|
||||||
|
funnels: funnels,
|
||||||
|
authorized_site: site,
|
||||||
|
conn: conn
|
||||||
|
}) do
|
||||||
|
%{
|
||||||
|
funnels: render_many(funnels, __MODULE__, "funnel.json", authorized_site: site),
|
||||||
|
meta: render_metadata_links(%{}, :plugins_api_funnels_url, :index, conn.query_params)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render(
|
||||||
|
"funnel.json",
|
||||||
|
%{
|
||||||
|
funnel: funnel,
|
||||||
|
authorized_site: site
|
||||||
|
}
|
||||||
|
) do
|
||||||
|
goals = Enum.map(funnel.steps, & &1.goal)
|
||||||
|
|
||||||
|
%{
|
||||||
|
funnel: %{
|
||||||
|
name: funnel.name,
|
||||||
|
id: funnel.id,
|
||||||
|
steps:
|
||||||
|
render_many(goals, PlausibleWeb.Plugins.API.Views.Goal, "goal.json",
|
||||||
|
authorized_site: site
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
@ -118,6 +118,12 @@ defmodule PlausibleWeb.Router do
|
|||||||
get("/goals/:id", Goals, :get)
|
get("/goals/:id", Goals, :get)
|
||||||
put("/goals", Goals, :create)
|
put("/goals", Goals, :create)
|
||||||
|
|
||||||
|
on_ee do
|
||||||
|
get("/funnels/:id", Funnels, :get)
|
||||||
|
get("/funnels", Funnels, :index)
|
||||||
|
put("/funnels", Funnels, :create)
|
||||||
|
end
|
||||||
|
|
||||||
delete("/goals/:id", Goals, :delete)
|
delete("/goals/:id", Goals, :delete)
|
||||||
delete("/goals", Goals, :delete_bulk)
|
delete("/goals", Goals, :delete_bulk)
|
||||||
|
|
||||||
|
@ -228,8 +228,8 @@ defmodule PlausibleWeb.Api.StatsController.FunnelsTest do
|
|||||||
user: user,
|
user: user,
|
||||||
site: site
|
site: site
|
||||||
} do
|
} do
|
||||||
insert(:growth_subscription, user: user)
|
|
||||||
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
||||||
|
insert(:growth_subscription, user: user)
|
||||||
|
|
||||||
resp =
|
resp =
|
||||||
conn
|
conn
|
||||||
|
405
test/plausible_web/plugins/api/controllers/funnels_test.exs
Normal file
405
test/plausible_web/plugins/api/controllers/funnels_test.exs
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
defmodule PlausibleWeb.Plugins.API.Controllers.FunnelsTest do
|
||||||
|
use PlausibleWeb.PluginsAPICase, async: true
|
||||||
|
use Plausible
|
||||||
|
|
||||||
|
@moduletag :ee_only
|
||||||
|
|
||||||
|
on_ee do
|
||||||
|
alias PlausibleWeb.Plugins.API.Schemas
|
||||||
|
|
||||||
|
describe "examples" do
|
||||||
|
test "Funnel" do
|
||||||
|
assert_schema(
|
||||||
|
Schemas.Funnel.schema().example,
|
||||||
|
"Funnel",
|
||||||
|
spec()
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Funnel.CreateRequest" do
|
||||||
|
assert_schema(
|
||||||
|
Schemas.Funnel.CreateRequest.schema().example,
|
||||||
|
"Funnel.CreateRequest",
|
||||||
|
spec()
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unauthorized calls" do
|
||||||
|
for {method, url} <- [
|
||||||
|
{:get, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index)},
|
||||||
|
{:get, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, 1)},
|
||||||
|
{:put, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create, %{})}
|
||||||
|
] do
|
||||||
|
test "unauthorized call: #{method} #{url}", %{conn: conn} do
|
||||||
|
conn
|
||||||
|
|> unquote(method)(unquote(url))
|
||||||
|
|> json_response(401)
|
||||||
|
|> assert_schema("UnauthorizedError", spec())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get /funnels/:id" do
|
||||||
|
test "validates input out of the box", %{conn: conn, token: token, site: site} do
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, "hello")
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> get(url)
|
||||||
|
|> json_response(422)
|
||||||
|
|> assert_schema("UnprocessableEntityError", spec())
|
||||||
|
|
||||||
|
assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp
|
||||||
|
end
|
||||||
|
|
||||||
|
test "retrieves no funnel on non-existing ID", %{conn: conn, token: token, site: site} do
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, 9999)
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> get(url)
|
||||||
|
|> json_response(404)
|
||||||
|
|> assert_schema("NotFoundError", spec())
|
||||||
|
|
||||||
|
assert %{errors: [%{detail: "Plugins API: resource not found"}]} = resp
|
||||||
|
end
|
||||||
|
|
||||||
|
test "retrieves funnel by ID", %{conn: conn, site: site, token: token} do
|
||||||
|
{:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"})
|
||||||
|
|
||||||
|
{:ok, g2} =
|
||||||
|
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||||
|
|
||||||
|
{:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"})
|
||||||
|
|
||||||
|
{:ok, funnel} =
|
||||||
|
Plausible.Funnels.create(
|
||||||
|
site,
|
||||||
|
"Peek & buy",
|
||||||
|
[g1, g2, g3]
|
||||||
|
)
|
||||||
|
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, funnel.id)
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> get(url)
|
||||||
|
|> json_response(200)
|
||||||
|
|> assert_schema("Funnel", spec())
|
||||||
|
|
||||||
|
assert resp.funnel.id == funnel.id
|
||||||
|
assert resp.funnel.name == "Peek & buy"
|
||||||
|
[s1, s2, s3] = resp.funnel.steps
|
||||||
|
|
||||||
|
assert_schema(s1, "Goal.Pageview", spec())
|
||||||
|
assert_schema(s2, "Goal.Revenue", spec())
|
||||||
|
assert_schema(s3, "Goal.CustomEvent", spec())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get /funnels" do
|
||||||
|
test "returns an empty funnels list if there's none", %{
|
||||||
|
conn: conn,
|
||||||
|
token: token,
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index)
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> get(url)
|
||||||
|
|> json_response(200)
|
||||||
|
|> assert_schema("Funnel.ListResponse", spec())
|
||||||
|
|
||||||
|
assert resp.funnels == []
|
||||||
|
assert resp.meta.pagination.has_next_page == false
|
||||||
|
assert resp.meta.pagination.has_prev_page == false
|
||||||
|
assert resp.meta.pagination.links == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "retrieves all funnels", %{conn: conn, site: site, token: token} do
|
||||||
|
{:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"})
|
||||||
|
|
||||||
|
{:ok, g2} =
|
||||||
|
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||||
|
|
||||||
|
{:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"})
|
||||||
|
|
||||||
|
for i <- 1..3 do
|
||||||
|
{:ok, _} =
|
||||||
|
Plausible.Funnels.create(
|
||||||
|
site,
|
||||||
|
"Funnel #{i}",
|
||||||
|
[g1, g2, g3]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index)
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> get(url)
|
||||||
|
|> json_response(200)
|
||||||
|
|> assert_schema("Funnel.ListResponse", spec())
|
||||||
|
|
||||||
|
assert Enum.count(resp.funnels) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "retrieves funnels with pagination", %{conn: conn, site: site, token: token} do
|
||||||
|
{:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"})
|
||||||
|
|
||||||
|
{:ok, g2} =
|
||||||
|
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||||
|
|
||||||
|
{:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"})
|
||||||
|
|
||||||
|
initial_order = Enum.shuffle([g1, g2, g3])
|
||||||
|
|
||||||
|
for i <- 1..3 do
|
||||||
|
{:ok, _} =
|
||||||
|
Plausible.Funnels.create(
|
||||||
|
site,
|
||||||
|
"Funnel #{i}",
|
||||||
|
initial_order
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index, limit: 2)
|
||||||
|
initial_conn = authenticate(conn, site.domain, token)
|
||||||
|
|
||||||
|
page1 =
|
||||||
|
initial_conn
|
||||||
|
|> get(url)
|
||||||
|
|> json_response(200)
|
||||||
|
|> assert_schema("Funnel.ListResponse", spec())
|
||||||
|
|
||||||
|
assert Enum.count(page1.funnels) == 2
|
||||||
|
assert page1.meta.pagination.has_next_page == true
|
||||||
|
assert page1.meta.pagination.has_prev_page == false
|
||||||
|
|
||||||
|
assert [%{funnel: %{steps: steps}}, %{funnel: %{steps: steps}}] = page1.funnels
|
||||||
|
assert Enum.map(steps, & &1.goal.id) == Enum.map(initial_order, & &1.id)
|
||||||
|
|
||||||
|
page2 =
|
||||||
|
initial_conn
|
||||||
|
|> get(page1.meta.pagination.links.next.url)
|
||||||
|
|> json_response(200)
|
||||||
|
|> assert_schema("Funnel.ListResponse", spec())
|
||||||
|
|
||||||
|
assert Enum.count(page2.funnels) == 1
|
||||||
|
assert page2.meta.pagination.has_next_page == false
|
||||||
|
assert page2.meta.pagination.has_prev_page == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "put /funnels - funnel creation" do
|
||||||
|
test "creates a funnel including its goals", %{conn: conn, token: token, site: site} do
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||||
|
|
||||||
|
payload = %{
|
||||||
|
funnel: %{
|
||||||
|
name: "My Test Funnel",
|
||||||
|
steps: [
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.CustomEvent",
|
||||||
|
goal: %{event_name: "Signup"}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Pageview",
|
||||||
|
goal: %{path: "/checkout"}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Revenue",
|
||||||
|
goal: %{event_name: "Purchase", currency: "EUR"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_request_schema(payload, "Funnel.CreateRequest", spec())
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put(url, payload)
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> json_response(201)
|
||||||
|
|> assert_schema("Funnel", spec())
|
||||||
|
|
||||||
|
[location] = get_resp_header(conn, "location")
|
||||||
|
|
||||||
|
assert location ==
|
||||||
|
Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, resp.funnel.id)
|
||||||
|
|
||||||
|
funnel = Plausible.Funnels.get(site, resp.funnel.id)
|
||||||
|
|
||||||
|
assert funnel.name == resp.funnel.name
|
||||||
|
assert funnel.site_id == site.id
|
||||||
|
assert Enum.count(funnel.steps) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fails for insufficient plan", %{conn: conn, token: token, site: site} do
|
||||||
|
site = Plausible.Repo.preload(site, :owner)
|
||||||
|
insert(:growth_subscription, user: site.owner)
|
||||||
|
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||||
|
|
||||||
|
payload = %{
|
||||||
|
funnel: %{
|
||||||
|
name: "My Test Funnel",
|
||||||
|
steps: [
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.CustomEvent",
|
||||||
|
goal: %{event_name: "Signup"}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Pageview",
|
||||||
|
goal: %{path: "/checkout"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_request_schema(payload, "Funnel.CreateRequest", spec())
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put(url, payload)
|
||||||
|
|> json_response(402)
|
||||||
|
|> assert_schema("PaymentRequiredError", spec())
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fails with only one step - guarded by the schema", %{
|
||||||
|
conn: conn,
|
||||||
|
token: token,
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||||
|
|
||||||
|
payload = %{
|
||||||
|
funnel: %{
|
||||||
|
name: "My Test Funnel",
|
||||||
|
steps: [
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.CustomEvent",
|
||||||
|
goal: %{event_name: "Signup"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put(url, payload)
|
||||||
|
|> json_response(422)
|
||||||
|
|> assert_schema("UnprocessableEntityError", spec())
|
||||||
|
|
||||||
|
assert %{errors: [%{detail: "Array length 1 is smaller than minItems: 2"}]} = resp
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is idempotent on full creation", %{conn: conn, token: token, site: site} do
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||||
|
|
||||||
|
{:ok, _g1} =
|
||||||
|
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||||
|
|
||||||
|
payload = %{
|
||||||
|
funnel: %{
|
||||||
|
name: "My Test Funnel",
|
||||||
|
steps: [
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.CustomEvent",
|
||||||
|
goal: %{event_name: "Signup"}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Pageview",
|
||||||
|
goal: %{path: "/checkout"}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Revenue",
|
||||||
|
goal: %{event_name: "Purchase", currency: "EUR"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_request_schema(payload, "Funnel.CreateRequest", spec())
|
||||||
|
|
||||||
|
initial_conn =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|
||||||
|
resp1 =
|
||||||
|
initial_conn
|
||||||
|
|> put(url, payload)
|
||||||
|
|> json_response(201)
|
||||||
|
|> assert_schema("Funnel", spec())
|
||||||
|
|
||||||
|
resp2 =
|
||||||
|
initial_conn
|
||||||
|
|> put(url, payload)
|
||||||
|
|> json_response(201)
|
||||||
|
|> assert_schema("Funnel", spec())
|
||||||
|
|
||||||
|
assert resp1.funnel == resp2.funnel
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edge case - different currency goal already exists", %{
|
||||||
|
conn: conn,
|
||||||
|
token: token,
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
|
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||||
|
|
||||||
|
{:ok, _g1} =
|
||||||
|
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"})
|
||||||
|
|
||||||
|
payload = %{
|
||||||
|
funnel: %{
|
||||||
|
name: "My Test Funnel",
|
||||||
|
steps: [
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.CustomEvent",
|
||||||
|
goal: %{event_name: "Signup"}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Pageview",
|
||||||
|
goal: %{path: "/checkout"}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
goal_type: "Goal.Revenue",
|
||||||
|
goal: %{event_name: "Purchase", currency: "EUR"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_request_schema(payload, "Funnel.CreateRequest", spec())
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> authenticate(site.domain, token)
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put(url, payload)
|
||||||
|
|> json_response(422)
|
||||||
|
|> assert_schema("UnprocessableEntityError", spec())
|
||||||
|
|
||||||
|
assert [%{detail: "event_name: 'Purchase' (with currency: USD) has already been taken"}] =
|
||||||
|
resp.errors
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue
Block a user