| 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), |
| |
| 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 |
| ) |
| ) |
|
|
| |
|
|
| :ok |
| end |
|
|
| defp filter_by_range(query, range_in_site_tz) do |
| |
| |
| minute_granularity_range = |
| range_in_site_tz |> DateTimeRange.to_timezone("Etc/UTC") |
|
|
| |
| |
| [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 |
|
|
| |
| |
| |
| 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 |
|
|