515 lines
14 KiB
Elixir
515 lines
14 KiB
Elixir
defmodule Plausible.Sites do
|
|
@moduledoc """
|
|
Sites context functions.
|
|
"""
|
|
use Plausible
|
|
|
|
import Ecto.Query
|
|
|
|
alias Plausible.{Auth, Repo, Site, Teams, Billing}
|
|
alias Plausible.Billing.Feature.SharedLinks
|
|
alias Plausible.Site.SharedLink
|
|
|
|
require Plausible.Site.UserPreference
|
|
|
|
on_ee do
|
|
@spec regular?(Site.t()) :: boolean()
|
|
def regular?(%Site{} = site), do: not site.consolidated
|
|
|
|
@spec consolidated?(Site.t()) :: boolean()
|
|
def consolidated?(%Site{} = site), do: site.consolidated
|
|
|
|
def site_id_query_filter(%Site{} = site) do
|
|
if consolidated?(site) do
|
|
site_ids = Plausible.ConsolidatedView.Cache.get(site.domain)
|
|
dynamic([x], fragment("? in ?", x.site_id, ^site_ids))
|
|
else
|
|
dynamic([x], x.site_id == ^site.id)
|
|
end
|
|
end
|
|
else
|
|
@spec regular?(Site.t()) :: boolean()
|
|
def regular?(%Site{}), do: always(true)
|
|
|
|
@spec consolidated?(Site.t()) :: boolean()
|
|
def consolidated?(%Site{}), do: always(false)
|
|
|
|
def site_id_query_filter(%Site{} = site) do
|
|
dynamic([x], x.site_id == ^site.id)
|
|
end
|
|
end
|
|
|
|
def display_name(%Site{} = site, opts \\ []) do
|
|
if consolidated?(site) do
|
|
"#{if opts[:capitalize_consolidated], do: "C", else: "c"}onsolidated view"
|
|
else
|
|
site.domain
|
|
end
|
|
end
|
|
|
|
@shared_link_special_names ["WordPress - Shared Dashboard"]
|
|
@doc """
|
|
Special shared link names are used to distinguish between those
|
|
created by the Plugins API, and those created in any other way
|
|
(i.e. via Sites API or in the Dashboard Site Settings UI).
|
|
|
|
The intent is to give our WP plugin the ability to display an
|
|
embedded dashboard even when the user's subscription does not
|
|
support the shared links feature.
|
|
|
|
A shared link with a special name can only be created via the
|
|
plugins API, and it will not show up under the list of shared
|
|
links in Site Settings > Visibility.
|
|
|
|
Once created with the special name, the link will be accessible
|
|
even when the team does not have access to SharedLinks feature.
|
|
"""
|
|
def shared_link_special_names(), do: @shared_link_special_names
|
|
|
|
def get_by_domain(domain, opts \\ []) do
|
|
include_consolidated? = Keyword.get(opts, :include_consolidated?, false)
|
|
|
|
if include_consolidated? do
|
|
Repo.get_by(Site, domain: domain)
|
|
else
|
|
Repo.get_by(Site.regular(), domain: domain)
|
|
end
|
|
end
|
|
|
|
def get_by_domain!(domain, opts \\ []) do
|
|
include_consolidated? = Keyword.get(opts, :include_consolidated?, false)
|
|
|
|
if include_consolidated? do
|
|
Repo.get_by!(Site, domain: domain)
|
|
else
|
|
Repo.get_by!(Site.regular(), domain: domain)
|
|
end
|
|
end
|
|
|
|
@spec toggle_pin(Auth.User.t(), Site.t()) ::
|
|
{:ok, Site.UserPreference.t()} | {:error, :too_many_pins}
|
|
def toggle_pin(user, site) do
|
|
pinned_at =
|
|
if site.pinned_at do
|
|
nil
|
|
else
|
|
NaiveDateTime.utc_now()
|
|
end
|
|
|
|
with :ok <- check_user_pin_limit(user, pinned_at) do
|
|
{:ok, set_option(user, site, :pinned_at, pinned_at)}
|
|
end
|
|
end
|
|
|
|
@pins_limit 9
|
|
|
|
defp check_user_pin_limit(_user, nil), do: :ok
|
|
|
|
defp check_user_pin_limit(user, _) do
|
|
pins_count =
|
|
from(up in Site.UserPreference,
|
|
where: up.user_id == ^user.id and not is_nil(up.pinned_at)
|
|
)
|
|
|> Repo.aggregate(:count)
|
|
|
|
if pins_count + 1 > @pins_limit do
|
|
{:error, :too_many_pins}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
@spec set_option(Auth.User.t(), Site.t(), atom(), any()) :: Site.UserPreference.t()
|
|
def set_option(user, site, option, value) when option in Site.UserPreference.options() do
|
|
get_for_user!(user, site.domain)
|
|
|
|
user
|
|
|> Site.UserPreference.changeset(site, %{option => value})
|
|
|> Repo.insert!(
|
|
conflict_target: [:user_id, :site_id],
|
|
# This way of conflict handling enables doing upserts of options leaving
|
|
# existing, unrelated values intact.
|
|
on_conflict: from(p in Site.UserPreference, update: [set: [{^option, ^value}]]),
|
|
returning: true
|
|
)
|
|
end
|
|
|
|
defdelegate list(user, pagination_params, opts \\ []), to: Plausible.Teams.Sites
|
|
|
|
defdelegate list_with_invitations(user, pagination_params, opts \\ []),
|
|
to: Plausible.Teams.Sites
|
|
|
|
def list_people(site) do
|
|
owner_memberships =
|
|
from(
|
|
tm in Teams.Membership,
|
|
inner_join: u in assoc(tm, :user),
|
|
where: tm.team_id == ^site.team_id,
|
|
where: tm.role == :owner,
|
|
select: %{
|
|
user: u,
|
|
role: tm.role
|
|
}
|
|
)
|
|
|> Repo.all()
|
|
|
|
memberships =
|
|
from(
|
|
gm in Teams.GuestMembership,
|
|
inner_join: tm in assoc(gm, :team_membership),
|
|
inner_join: u in assoc(tm, :user),
|
|
where: gm.site_id == ^site.id,
|
|
select: %{
|
|
user: u,
|
|
role: gm.role
|
|
}
|
|
)
|
|
|> Repo.all()
|
|
|
|
memberships = owner_memberships ++ memberships
|
|
|
|
invitations =
|
|
from(
|
|
gi in Teams.GuestInvitation,
|
|
inner_join: ti in assoc(gi, :team_invitation),
|
|
where: gi.site_id == ^site.id,
|
|
select: %{
|
|
invitation_id: gi.invitation_id,
|
|
email: ti.email,
|
|
role: gi.role
|
|
}
|
|
)
|
|
|> Repo.all()
|
|
|
|
site_transfers =
|
|
from(
|
|
st in Teams.SiteTransfer,
|
|
where: st.site_id == ^site.id,
|
|
select: %{
|
|
invitation_id: st.transfer_id,
|
|
email: st.email,
|
|
role: :owner
|
|
}
|
|
)
|
|
|> Repo.all()
|
|
|
|
%{memberships: memberships, invitations: site_transfers ++ invitations}
|
|
end
|
|
|
|
@spec list_guests_query(Site.t(), Keyword.t()) :: Ecto.Query.t()
|
|
def list_guests_query(site, opts \\ []) do
|
|
guest_memberships =
|
|
from(
|
|
gm in Teams.GuestMembership,
|
|
inner_join: tm in assoc(gm, :team_membership),
|
|
inner_join: u in assoc(tm, :user),
|
|
as: :user,
|
|
where: gm.site_id == ^site.id,
|
|
select: %{
|
|
id: gm.id,
|
|
inserted_at: gm.inserted_at,
|
|
email: u.email,
|
|
role: gm.role,
|
|
status: "accepted"
|
|
}
|
|
)
|
|
|
|
guest_memberships =
|
|
if email = opts[:email] do
|
|
guest_memberships |> where([user: u], u.email == ^email)
|
|
else
|
|
guest_memberships
|
|
end
|
|
|
|
guest_invitations =
|
|
from(
|
|
gi in Teams.GuestInvitation,
|
|
inner_join: ti in assoc(gi, :team_invitation),
|
|
as: :team_invitation,
|
|
where: gi.site_id == ^site.id,
|
|
select: %{
|
|
id: gi.id,
|
|
inserted_at: gi.inserted_at,
|
|
email: ti.email,
|
|
role: gi.role,
|
|
status: "invited"
|
|
}
|
|
)
|
|
|
|
guest_invitations =
|
|
if email = opts[:email] do
|
|
guest_invitations |> where([team_invitation: ti], ti.email == ^email)
|
|
else
|
|
guest_invitations
|
|
end
|
|
|
|
guests = union_all(guest_memberships, ^guest_invitations)
|
|
|
|
from(g in subquery(guests),
|
|
select: %{
|
|
id: g.id,
|
|
inserted_at: g.inserted_at,
|
|
email: g.email,
|
|
role: g.role,
|
|
status: g.status
|
|
},
|
|
order_by: [desc: g.inserted_at, desc: g.id]
|
|
)
|
|
end
|
|
|
|
@spec for_user_query(Auth.User.t(), Teams.Team.t() | nil) :: Ecto.Query.t()
|
|
def for_user_query(user, team \\ nil) do
|
|
query =
|
|
from(s in Site.regular(),
|
|
as: :site,
|
|
inner_join: t in assoc(s, :team),
|
|
as: :team,
|
|
inner_join: tm in assoc(t, :team_memberships),
|
|
as: :team_memberships,
|
|
left_join: gm in assoc(tm, :guest_memberships),
|
|
as: :guest_memberships,
|
|
where: tm.user_id == ^user.id,
|
|
order_by: [desc: s.id]
|
|
)
|
|
|
|
if team do
|
|
where(
|
|
query,
|
|
[team_memberships: tm, guest_memberships: gm, site: s],
|
|
tm.role != :guest and tm.team_id == ^team.id
|
|
)
|
|
else
|
|
where(
|
|
query,
|
|
[team_memberships: tm, guest_memberships: gm, site: s],
|
|
tm.role != :guest or gm.site_id == s.id
|
|
)
|
|
end
|
|
end
|
|
|
|
def create(user, params, team \\ nil) do
|
|
Ecto.Multi.new()
|
|
|> Ecto.Multi.put(:site_changeset, Site.new(params))
|
|
|> Ecto.Multi.run(:create_team, fn _repo, _context ->
|
|
cond do
|
|
team && Teams.Memberships.can_add_site?(team, user) ->
|
|
{:ok, Teams.with_subscription(team)}
|
|
|
|
is_nil(team) ->
|
|
with {:ok, team} <- Teams.get_or_create(user) do
|
|
{:ok, Teams.with_subscription(team)}
|
|
end
|
|
|
|
true ->
|
|
{:error, :permission_denied}
|
|
end
|
|
end)
|
|
|> Ecto.Multi.run(:ensure_can_add_new_site, fn _repo, %{create_team: team} ->
|
|
case Teams.Billing.ensure_can_add_new_site(team) do
|
|
:ok -> {:ok, :proceed}
|
|
error -> error
|
|
end
|
|
end)
|
|
|> Ecto.Multi.run(:clear_changed_from, fn
|
|
_repo, %{site_changeset: %{changes: %{domain: domain}}} ->
|
|
case get_for_user(user, domain, roles: [:owner]) do
|
|
%Site{domain_changed_from: ^domain} = site ->
|
|
site
|
|
|> Ecto.Changeset.change()
|
|
|> Ecto.Changeset.put_change(:domain_changed_from, nil)
|
|
|> Ecto.Changeset.put_change(:domain_changed_at, nil)
|
|
|> Repo.update()
|
|
|
|
_ ->
|
|
{:ok, :ignore}
|
|
end
|
|
|
|
_repo, _context ->
|
|
{:ok, :ignore}
|
|
end)
|
|
|> Ecto.Multi.insert(:site, fn %{site_changeset: site, create_team: team} ->
|
|
Ecto.Changeset.put_assoc(site, :team, team)
|
|
end)
|
|
|> Ecto.Multi.run(:trial, fn _repo, %{create_team: team} ->
|
|
if is_nil(team.trial_expiry_date) and is_nil(team.subscription) do
|
|
Teams.start_trial(team)
|
|
{:ok, :trial_started}
|
|
else
|
|
{:ok, :trial_already_started}
|
|
end
|
|
end)
|
|
|> Ecto.Multi.run(:updated_lock, fn _repo, %{create_team: team} ->
|
|
lock_state =
|
|
if ee?() do
|
|
Billing.SiteLocker.update_for(team, send_email?: false)
|
|
else
|
|
:unlocked
|
|
end
|
|
|
|
{:ok, lock_state}
|
|
end)
|
|
|> Repo.transaction()
|
|
end
|
|
|
|
@spec clear_stats_start_date!(Site.t()) :: Site.t()
|
|
def clear_stats_start_date!(site) do
|
|
site
|
|
|> Ecto.Changeset.change(stats_start_date: nil)
|
|
|> Plausible.Repo.update!()
|
|
end
|
|
|
|
@spec stats_start_date(Site.t()) :: Date.t() | nil
|
|
@doc """
|
|
Returns the date of the first event of the given site, or `nil` if the site
|
|
does not have stats yet.
|
|
|
|
If this is the first time the function is called for the site, it queries
|
|
imported stats and Clickhouse, choosing the earliest start date and saves
|
|
it in the sites table.
|
|
"""
|
|
def stats_start_date(site)
|
|
|
|
on_ee do
|
|
# for now, we're going to always update consolidated views
|
|
def stats_start_date(%Site{consolidated: true} = site) do
|
|
team = Repo.preload(site, :team).team
|
|
|
|
site
|
|
|> Plausible.ConsolidatedView.change_stats_dates(team)
|
|
|> Repo.update!()
|
|
|> Map.fetch!(:stats_start_date)
|
|
end
|
|
end
|
|
|
|
def stats_start_date(%Site{stats_start_date: %Date{} = date}) do
|
|
date
|
|
end
|
|
|
|
def stats_start_date(%Site{} = site) do
|
|
start_date =
|
|
[
|
|
Plausible.Imported.earliest_import_start_date(site),
|
|
native_stats_start_date(site)
|
|
]
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Enum.min(Date, fn -> nil end)
|
|
|
|
if start_date do
|
|
updated_site =
|
|
site
|
|
|> Site.set_stats_start_date(start_date)
|
|
|> Repo.update!()
|
|
|
|
updated_site.stats_start_date
|
|
end
|
|
end
|
|
|
|
@spec native_stats_start_date(Site.t()) :: Date.t() | nil
|
|
def native_stats_start_date(site) do
|
|
Plausible.Stats.Clickhouse.pageview_start_date_local(site)
|
|
end
|
|
|
|
def has_stats?(site) do
|
|
!!stats_start_date(site)
|
|
end
|
|
|
|
def create_shared_link(site, name, opts \\ []) do
|
|
password = Keyword.get(opts, :password)
|
|
site = Plausible.Repo.preload(site, :team)
|
|
skip_feature_check? = Keyword.get(opts, :skip_feature_check?, false)
|
|
|
|
if not skip_feature_check? and SharedLinks.check_availability(site.team) != :ok do
|
|
{:error, :upgrade_required}
|
|
else
|
|
%SharedLink{site_id: site.id, slug: Nanoid.generate()}
|
|
|> SharedLink.changeset(
|
|
%{name: name, password: password},
|
|
Keyword.take(opts, [:skip_special_name_check?])
|
|
)
|
|
|> Repo.insert()
|
|
end
|
|
end
|
|
|
|
def shared_link_url(site, link) do
|
|
base = PlausibleWeb.Endpoint.url()
|
|
domain = "/share/#{URI.encode_www_form(site.domain)}"
|
|
base <> domain <> "?auth=" <> link.slug
|
|
end
|
|
|
|
def update_legacy_time_on_page_cutoff!(site, cutoff) do
|
|
site
|
|
|> Ecto.Changeset.change()
|
|
|> Ecto.Changeset.put_change(:legacy_time_on_page_cutoff, cutoff)
|
|
|> Repo.update!()
|
|
end
|
|
|
|
def has_goals?(site) do
|
|
Repo.exists?(
|
|
from(g in Plausible.Goal,
|
|
where: g.site_id == ^site.id
|
|
)
|
|
)
|
|
end
|
|
|
|
def get_for_user!(user, domain, opts \\ []) do
|
|
opts = default_get_for_user_opts(opts)
|
|
roles = Keyword.fetch!(opts, :roles)
|
|
include_consolidated? = Keyword.fetch!(opts, :include_consolidated?)
|
|
|
|
site =
|
|
if :super_admin in roles and Plausible.Auth.is_super_admin?(user.id) do
|
|
get_by_domain!(domain, include_consolidated?: include_consolidated?)
|
|
else
|
|
user.id
|
|
|> get_for_user_query(domain, List.delete(roles, :super_admin), opts)
|
|
|> Repo.one!()
|
|
end
|
|
|
|
Repo.preload(site, :team)
|
|
end
|
|
|
|
def get_for_user(user, domain, opts \\ []) do
|
|
opts = default_get_for_user_opts(opts)
|
|
roles = Keyword.fetch!(opts, :roles)
|
|
include_consolidated? = Keyword.fetch!(opts, :include_consolidated?)
|
|
|
|
if :super_admin in roles and Plausible.Auth.is_super_admin?(user.id) do
|
|
get_by_domain(domain, include_consolidated?: include_consolidated?)
|
|
else
|
|
user.id
|
|
|> get_for_user_query(domain, List.delete(roles, :super_admin), opts)
|
|
|> Repo.one()
|
|
end
|
|
end
|
|
|
|
defp get_for_user_query(user_id, domain, roles, opts) do
|
|
include_consolidated? = Keyword.fetch!(opts, :include_consolidated?)
|
|
roles = Enum.map(roles, &to_string/1)
|
|
|
|
q =
|
|
from(s in Site,
|
|
join: t in assoc(s, :team),
|
|
join: tm in assoc(t, :team_memberships),
|
|
left_join: gm in assoc(tm, :guest_memberships),
|
|
where: tm.user_id == ^user_id,
|
|
where: coalesce(gm.role, tm.role) in ^roles,
|
|
where: s.domain == ^domain or s.domain_changed_from == ^domain,
|
|
where: is_nil(gm.id) or gm.site_id == s.id,
|
|
select: s
|
|
)
|
|
|
|
if include_consolidated? do
|
|
q
|
|
else
|
|
from(s in Site.regular(q))
|
|
end
|
|
end
|
|
|
|
defp default_get_for_user_opts(opts) do
|
|
Keyword.merge(
|
|
[include_consolidated?: false, roles: [:owner, :admin, :editor, :viewer]],
|
|
opts
|
|
)
|
|
end
|
|
end
|