analytics / lib /plausible /annotations /annotations.ex
Leon4gr45's picture
Upload folder using huggingface_hub
3e21b19 verified
Raw
History Blame Contribute Delete
11.7 kB
defmodule Plausible.Annotations do
@moduledoc """
Module for accessing Annotations.
"""
alias Plausible.Annotations.Annotation
alias Plausible.Auth.User
alias Plausible.Repo
alias Plausible.Stats.DateTimeRange
import Ecto.Query
@roles_with_personal_annotations [:billing, :viewer, :editor, :admin, :owner, :super_admin]
@roles_with_maybe_site_annotations [:editor, :admin, :owner, :super_admin]
@type error_not_enough_permissions() :: :not_enough_permissions
@type error_annotation_not_found() :: :annotation_not_found
@type error_annotation_limit_reached() :: :annotations_limit_reached
@type error_invalid_annotation() :: {:invalid_annotation, Keyword.t()}
@max_annotations 500
@spec roles_with_personal_annotations() :: [atom()]
def roles_with_personal_annotations(), do: @roles_with_personal_annotations
@spec roles_with_maybe_site_annotations() :: [atom()]
def roles_with_maybe_site_annotations(), do: @roles_with_maybe_site_annotations
@spec site_annotations_available?(Plausible.Site.t()) :: boolean()
def site_annotations_available?(site),
# this feature is bundled with SiteSegments
do: Plausible.Billing.Feature.SiteSegments.check_availability(site.team) == :ok
@spec get_all_for_site(Plausible.Site.t(), atom(), User.t() | nil, DateTimeRange.t()) ::
{:error, error_not_enough_permissions()} | {:ok, list(Annotation.t())}
def get_all_for_site(site, site_role, user, range_in_site_tz) do
fields = [:id, :note, :type, :datetime, :granularity, :site_id, :inserted_at, :updated_at]
cond do
site_role in [:public] ->
annotations =
Repo.all(
from(annotation in Annotation,
inner_join: site in assoc(annotation, :site),
select: ^fields,
where: annotation.site_id == ^site.id,
where: annotation.type == :site,
order_by: [asc: annotation.datetime, desc: annotation.id],
preload: [site: site]
)
|> filter_by_range(range_in_site_tz)
)
{:ok, annotations}
site_role in roles_with_personal_annotations() or
site_role in roles_with_maybe_site_annotations() ->
fields = fields ++ [:owner_id]
annotations =
Repo.all(
from(annotation in Annotation,
inner_join: site in assoc(annotation, :site),
left_join: owner in assoc(annotation, :owner),
select: ^fields,
where: annotation.site_id == ^site.id,
where:
annotation.type == :site or
(annotation.type == :personal and annotation.owner_id == ^user.id),
order_by: [asc: annotation.datetime, desc: annotation.id],
preload: [site: site, owner: owner]
)
|> filter_by_range(range_in_site_tz)
)
{:ok, annotations}
true ->
{:error, :not_enough_permissions}
end
end
@spec get_one(User.t() | nil, Plausible.Site.t(), atom(), pos_integer() | nil) ::
{:ok, Annotation.t()}
| {:error, error_not_enough_permissions() | error_annotation_not_found()}
def get_one(nil, _site, _site_role, _annotation_id) do
{:error, :not_enough_permissions}
end
def get_one(user, site, site_role, annotation_id) do
if site_role in roles_with_personal_annotations() do
case do_get_one(user, site, annotation_id) do
%Annotation{} = annotation -> {:ok, annotation}
nil -> {:error, :annotation_not_found}
end
else
{:error, :not_enough_permissions}
end
end
@spec insert_one(User.t() | nil, Plausible.Site.t(), atom(), map()) ::
{:ok, Annotation.t()}
| {:error,
error_not_enough_permissions()
| error_invalid_annotation()
| error_annotation_limit_reached()}
def insert_one(nil, _site, _site_role, _params) do
{:error, :not_enough_permissions}
end
def insert_one(user, site, site_role, params) do
changeset = Annotation.create_changeset(params, site, user)
annotation_type = Ecto.Changeset.get_field(changeset, :type)
with :ok <- can_insert_one?(site, site_role, annotation_type),
{:ok, annotation} <- Repo.insert(changeset) do
{:ok, Repo.preload(annotation, [:site, :owner])}
else
{:error, %Ecto.Changeset{errors: errors}} ->
{:error, {:invalid_annotation, errors}}
{:error, _type} = error ->
error
end
end
@spec update_one(User.t() | nil, Plausible.Site.t(), atom(), pos_integer(), map()) ::
{:ok, Annotation.t()}
| {:error,
error_not_enough_permissions()
| error_invalid_annotation()
| error_annotation_not_found()}
def update_one(nil, _site, _site_role, _annotation_id, _params) do
{:error, :not_enough_permissions}
end
def update_one(user, site, site_role, annotation_id, params) do
with {:ok, annotation} <- get_one(user, site, site_role, annotation_id),
changeset = Annotation.update_changeset(annotation, params, user),
new_annotation_type = Ecto.Changeset.get_field(changeset, :type),
:ok <- can_update_one?(site, site_role, new_annotation_type, annotation.type),
{:ok, annotation} <- Repo.update(changeset) do
{:ok, Repo.preload(annotation, [:site, :owner])}
else
{:error, %Ecto.Changeset{errors: errors}} ->
{:error, {:invalid_annotation, errors}}
{:error, _type} = error ->
error
end
end
@spec delete_one(User.t() | nil, Plausible.Site.t(), atom(), pos_integer()) ::
{:ok, Annotation.t()}
| {:error,
error_not_enough_permissions()
| error_annotation_not_found()}
def delete_one(nil, _site, _site_role, _annotation_id) do
{:error, :not_enough_permissions}
end
def delete_one(user, site, site_role, annotation_id) do
with {:ok, annotation} <- get_one(user, site, site_role, annotation_id) do
cond do
annotation.type == :site and site_role in roles_with_maybe_site_annotations() ->
{:ok, do_delete_one(annotation)}
annotation.type == :personal and site_role in roles_with_personal_annotations() ->
{:ok, do_delete_one(annotation)}
true ->
{:error, :not_enough_permissions}
end
end
end
@spec after_user_removed_from_site(Plausible.Site.t(), User.t()) :: :ok
def after_user_removed_from_site(site, user) do
Repo.delete_all(
from(annotation in Annotation,
where: annotation.site_id == ^site.id,
where: annotation.owner_id == ^user.id,
where: annotation.type == :personal
)
)
Repo.update_all(
from(annotation in Annotation,
where: annotation.site_id == ^site.id,
where: annotation.owner_id == ^user.id,
where: annotation.type == :site,
update: [set: [owner_id: nil]]
),
[]
)
:ok
end
@spec after_user_removed_from_team(Plausible.Teams.Team.t(), User.t()) :: :ok
def after_user_removed_from_team(team, user) do
team_sites_q =
from(
site in Plausible.Site,
where: site.team_id == ^team.id,
where: parent_as(:annotation).site_id == site.id
)
Repo.delete_all(
from(annotation in Annotation,
as: :annotation,
where: annotation.owner_id == ^user.id,
where: annotation.type == :personal,
where: exists(team_sites_q)
)
)
Repo.update_all(
from(annotation in Annotation,
as: :annotation,
where: annotation.owner_id == ^user.id,
where: annotation.type == :site,
where: exists(team_sites_q),
update: [set: [owner_id: nil]]
),
[]
)
:ok
end
@spec user_removed(User.t()) :: :ok
def user_removed(user) do
Repo.delete_all(
from(annotation in Annotation,
as: :annotation,
where: annotation.owner_id == ^user.id,
where: annotation.type == :personal
)
)
# Site annotations are set to owner=null via ON DELETE SET NULL
:ok
end
defp filter_by_range(query, range_in_site_tz) do
# Minute granularity annotations are stored for the particular UTC moment they're for,
# so they must be in the range of the UTC query period.
minute_granularity_range =
range_in_site_tz |> DateTimeRange.to_timezone("Etc/UTC")
# Date granularity annotations are stored for the UTC midnight of the date they're for,
# so the range for querying these must reflect that.
[date_granularity_range_first, date_granularity_range_last] =
[range_in_site_tz.first, range_in_site_tz.last]
|> Enum.map(fn utc_datetime ->
utc_datetime |> DateTime.to_date() |> Annotation.serialize_date_granularity_datetime()
end)
from(annotation in query,
where:
(annotation.granularity == :minute and
annotation.datetime >= ^minute_granularity_range.first and
annotation.datetime <= ^minute_granularity_range.last) or
(annotation.granularity == :date and
annotation.datetime >=
^date_granularity_range_first and
annotation.datetime <=
^date_granularity_range_last)
)
end
defp do_get_one(user, site, annotation_id)
defp do_get_one(_user, _site, nil) do
nil
end
defp do_get_one(user, site, annotation_id) do
query =
from(annotation in Annotation,
where: annotation.site_id == ^site.id,
where: annotation.id == ^annotation_id,
where:
annotation.type == :site or
(annotation.type == :personal and annotation.owner_id == ^user.id),
preload: [:site, :owner]
)
Repo.one(query)
end
defp do_delete_one(annotation) do
annotation
|> Repo.preload([:site, :owner])
|> Repo.delete!()
end
defp can_update_one?(site, site_role, new_annotation_type, existing_annotation_type) do
updating_to_site_annotation? = new_annotation_type == :site
updating_to_personal_annotation? = new_annotation_type == :personal
cond do
(existing_annotation_type == :site or
updating_to_site_annotation?) and site_role in roles_with_maybe_site_annotations() and
site_annotations_available?(site) ->
:ok
# Allow demoting a site annotation to personal even when
# site_annotations is no longer available on the plan — gives users a
# way to keep their annotations usable after a downgrade.
existing_annotation_type == :site and updating_to_personal_annotation? and
site_role in roles_with_maybe_site_annotations() ->
:ok
existing_annotation_type == :personal and updating_to_personal_annotation? and
site_role in roles_with_personal_annotations() ->
:ok
true ->
{:error, :not_enough_permissions}
end
end
defp can_insert_one?(site, site_role, annotation_type) do
cond do
count_annotations(site.id) >= @max_annotations ->
{:error, :annotations_limit_reached}
annotation_type == :site and site_role in roles_with_maybe_site_annotations() and
site_annotations_available?(site) ->
:ok
annotation_type == :personal and
site_role in roles_with_personal_annotations() ->
:ok
true ->
{:error, :not_enough_permissions}
end
end
defp count_annotations(site_id) do
from(annotation in Annotation,
where: annotation.site_id == ^site_id
)
|> Repo.aggregate(:count, :id)
end
end