diff --git a/lib/plausible/annotations/annotation.ex b/lib/plausible/annotations/annotation.ex new file mode 100644 index 000000000000..b01e13bd6197 --- /dev/null +++ b/lib/plausible/annotations/annotation.ex @@ -0,0 +1,206 @@ +defmodule Plausible.Annotations.Annotation do + @moduledoc """ + Schema for annotations. Annotations are notes attached to a point on the graph. + + Annotations can have two granularities, date or minute. + + To create date granularity annotations, only the date part must be specified for `datetime` field. + It's interpreted to be for that date, no matter what happens with the site timezone. + + Examples: + - `{"granularity": "date", "datetime": "2026-06-30", ...}`. + + To create minute granularity annotations, the full datetime needs to be specified for `datetime` field. + It can be set in local time, as a naive datetime string ("2026-06-29 10:00:00"), in which case it's + interpreted as that datetime in the site's timezone and stored as that particular moment in UTC. + Alternatively, it can be sent with TZ information ("2026-05-31T12:00:00Z", "2026-05-31T10:00:00-02:00"), + in which case it's interpreted to be that particular moment and stored as that in UTC. + + Examples: + - `{"granularity": "minute", "datetime": "2026-06-29 10:00:00", ...}` + - `{"granularity": "minute", "datetime": "2026-06-29T12:00:00Z", ...}` + """ + + use Plausible + use Ecto.Schema + import Ecto.Changeset + + @annotation_types [:personal, :site] + @annotation_granularities [:date, :minute] + + @type t() :: %__MODULE__{} + + schema "annotations" do + field :note, :string + field :type, Ecto.Enum, values: @annotation_types + field :date, :date, virtual: true + field :datetime, :utc_datetime + field :granularity, Ecto.Enum, values: @annotation_granularities + + # owner ID can be null (aka note is dangling) when the original owner is deassociated from the site + # the note is dangling until another user edits it: the editor becomes the new owner + belongs_to :owner, Plausible.Auth.User, foreign_key: :owner_id + belongs_to :site, Plausible.Site + + timestamps() + end + + def create_changeset(attrs, site, owner) do + %__MODULE__{} + |> changeset(attrs, site.timezone) + |> put_assoc(:site, site) + |> put_assoc(:owner, owner) + end + + def update_changeset(annotation, attrs, owner) do + changeset = + annotation + |> changeset(attrs, annotation.site.timezone) + + # Users that can edit site annotations can edit + # 1) dangling site annotations (owner_id: nil), + # 2) site annotations owned by other users, + # in both cases becoming the new owners. + case fetch_field!(changeset, :owner_id) do + nil -> changeset |> put_assoc(:owner, owner) + # This case works around Ecto's limitations in overwriting owner association + _ -> changeset |> put_change(:owner_id, owner.id) + end + end + + def changeset(annotation, attrs, site_timezone) do + annotation + |> cast(attrs, [:note, :type]) + |> cast(attrs, [:date, :datetime, :granularity], force_changes: true) + |> validate_required([:note, :type, :granularity]) + |> validate_length(:note, count: :bytes, min: 1, max: 255) + |> maybe_coerce_naive_datetime(site_timezone) + |> coerce_datetime() + end + + # If `datetime` is a naive ISO 8601 string (no UTC offset or Z suffix), interpret + # it as a local time in the site's timezone and convert to UTC before the changeset + # runs. This lets callers supply times in their local context without manually + # computing offsets. + # + # DST edge cases: + # - gap (spring-forward): the missing hour is resolved to just-after the gap + # - ambiguous (fall-back): the earlier of the two possibilities is used + # + # All other `datetime` values (bare dates, full UTC strings, invalid strings) pass + # through unchanged and are handled downstream by the changeset. + defp maybe_coerce_naive_datetime(changeset, timezone) do + with false <- is_nil(get_change(changeset, :datetime)), + dt when is_binary(dt) <- changeset.params["datetime"], + {:error, _} <- DateTime.from_iso8601(dt), + {:ok, naive_dt} <- NaiveDateTime.from_iso8601(dt) do + case DateTime.from_naive(naive_dt, timezone) do + {:ok, local_dt} -> + force_change(changeset, :datetime, to_utc(local_dt)) + + {:ambiguous, first, _second} -> + force_change(changeset, :datetime, to_utc(first)) + + {:gap, _just_before, just_after} -> + force_change(changeset, :datetime, to_utc(just_after)) + + {:error, _} -> + add_error( + changeset, + :datetime, + "cannot be parsed for the site timezone \"#{timezone}\"" + ) + end + else + _ -> changeset + end + end + + defp to_utc(%DateTime{} = dt), do: DateTime.shift_zone!(dt, "Etc/UTC") + + defp coerce_datetime(%{valid?: true} = changeset) do + granularity = get_change(changeset, :granularity) + datetime = get_change(changeset, :datetime) + date = get_change(changeset, :date) + + case coerce_for_granularity(granularity, date, datetime) do + {:ok, %DateTime{} = utc_dt} -> + put_change(changeset, :datetime, utc_dt) + + {:error, :not_supplied, field} -> + add_error(changeset, field, "must be supplied for chosen granularity") + + {:error, :both_set} -> + add_error(changeset, :granularity, "expects either date or datetime to be set") + + :skip -> + changeset + end + end + + defp coerce_datetime(changeset), do: changeset + + defp coerce_for_granularity(:date, %Date{} = date, nil), + do: {:ok, serialize_date_granularity_datetime(date)} + + defp coerce_for_granularity(:minute, nil, %DateTime{} = dt), + do: {:ok, dt} + + defp coerce_for_granularity(:date, nil, _), + do: {:error, :not_supplied, :date} + + defp coerce_for_granularity(:minute, _, nil), + do: {:error, :not_supplied, :datetime} + + defp coerce_for_granularity(granularity, _date, _datetime) + when granularity in [:date, :minute], + do: {:error, :both_set} + + defp coerce_for_granularity(_granularity, _date, _datetime), do: :skip + + def serialize_date_granularity_datetime(%Date{} = date), + do: DateTime.new!(date, ~T[00:00:00], "Etc/UTC") + + # Used only by encoder + @doc false + def localize(annotation, timezone) + + # For date granularity, the UTC date component IS the annotation date — callers + # store UTC midnight of their intended local date, so no timezone shift is needed. + # Return just the Date so the JSON response matches the bare-date input format. + def localize(%{granularity: :date} = annotation, _timezone) do + %{annotation | datetime: parse_date_granularity_datetime(annotation.datetime)} + end + + # For minute granularity, shift the stored UTC moment to the site's local timezone + # and strip the offset so the response is a naive local time string. + def localize(%{granularity: :minute} = annotation, timezone) do + naive_local = + annotation.datetime + |> DateTime.shift_zone!(timezone) + |> DateTime.to_naive() + + %{annotation | datetime: naive_local} + end + + defp parse_date_granularity_datetime(%DateTime{} = datetime), + do: DateTime.to_date(datetime) +end + +defimpl Jason.Encoder, for: Plausible.Annotations.Annotation do + def encode(annotation, opts) do + %{ + id: annotation.id, + note: annotation.note, + type: annotation.type, + datetime: annotation.datetime, + granularity: annotation.granularity, + owner_id: annotation.owner_id, + owner_name: if(annotation.owner_id, do: annotation.owner.name), + inserted_at: annotation.inserted_at, + updated_at: annotation.updated_at + } + |> Plausible.Annotations.Annotation.localize(annotation.site.timezone) + |> Jason.Encode.map(opts) + end +end diff --git a/lib/plausible/annotations/annotations.ex b/lib/plausible/annotations/annotations.ex new file mode 100644 index 000000000000..64d38b038795 --- /dev/null +++ b/lib/plausible/annotations/annotations.ex @@ -0,0 +1,340 @@ +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: [desc: annotation.updated_at, 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: [desc: annotation.updated_at, 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 + + 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 == :personal and not updating_to_site_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 diff --git a/lib/plausible/helpers/changeset.ex b/lib/plausible/helpers/changeset.ex index e7b6d88221ba..41fb0556899e 100644 --- a/lib/plausible/helpers/changeset.ex +++ b/lib/plausible/helpers/changeset.ex @@ -10,4 +10,25 @@ defmodule Plausible.ChangesetHelpers do end) end) end + + @doc """ + iex> serialize_first_error([{"name", {"should be at most %{count} byte(s)", [count: 255]}}]) + "name should be at most 255 byte(s)" + """ + def serialize_first_error(errors) do + {field, {message, opts}} = List.first(errors) + + formatted_message = + Enum.reduce(opts, message, fn {key, value}, acc -> + placeholder = "%{#{key}}" + + if String.contains?(acc, placeholder) do + String.replace(acc, placeholder, to_string(value)) + else + acc + end + end) + + "#{field} #{formatted_message}" + end end diff --git a/lib/plausible/segments/segments.ex b/lib/plausible/segments/segments.ex index de247316b4e5..8b1556e41c88 100644 --- a/lib/plausible/segments/segments.ex +++ b/lib/plausible/segments/segments.ex @@ -461,19 +461,4 @@ defmodule Plausible.Segments do def site_segments_available?(%Plausible.Site{} = site), do: Plausible.Billing.Feature.SiteSegments.check_availability(site.team) == :ok - - @doc """ - iex> serialize_first_error([{"name", {"should be at most %{count} byte(s)", [count: 255]}}]) - "name should be at most 255 byte(s)" - """ - def serialize_first_error(errors) do - {field, {message, opts}} = List.first(errors) - - formatted_message = - Enum.reduce(opts, message, fn {key, value}, acc -> - String.replace(acc, "%{#{key}}", to_string(value)) - end) - - "#{field} #{formatted_message}" - end end diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex index 26460c8af3c4..b3186422561f 100644 --- a/lib/plausible/teams/memberships.ex +++ b/lib/plausible/teams/memberships.ex @@ -167,6 +167,7 @@ defmodule Plausible.Teams.Memberships do Repo.delete!(guest_membership) prune_guests(guest_membership.team_membership.team) Plausible.Segments.after_user_removed_from_site(site, user) + Plausible.Annotations.after_user_removed_from_site(site, user) end) send_site_member_removed_email(guest_membership) diff --git a/lib/plausible/teams/memberships/leave.ex b/lib/plausible/teams/memberships/leave.ex index c0b94fdb5183..18436fcf6ba8 100644 --- a/lib/plausible/teams/memberships/leave.ex +++ b/lib/plausible/teams/memberships/leave.ex @@ -22,6 +22,11 @@ defmodule Plausible.Teams.Memberships.Leave do team_membership.team, team_membership.user ) + + Plausible.Annotations.after_user_removed_from_team( + team_membership.team, + team_membership.user + ) end) if Keyword.get(opts, :send_email?, true) do diff --git a/lib/plausible/teams/memberships/remove.ex b/lib/plausible/teams/memberships/remove.ex index 28bf1eedbf8a..52261f365027 100644 --- a/lib/plausible/teams/memberships/remove.ex +++ b/lib/plausible/teams/memberships/remove.ex @@ -23,6 +23,11 @@ defmodule Plausible.Teams.Memberships.Remove do team_membership.team, team_membership.user ) + + Plausible.Annotations.after_user_removed_from_team( + team_membership.team, + team_membership.user + ) end) if Keyword.get(opts, :send_email?, true) do diff --git a/lib/plausible/timezones.ex b/lib/plausible/timezones.ex index 030c41c95b42..1998f90a034c 100644 --- a/lib/plausible/timezones.ex +++ b/lib/plausible/timezones.ex @@ -8,6 +8,9 @@ defmodule Plausible.Timezones do Timex.is_valid_timezone?(tz) end + @spec zone_list() :: [String.t()] + def zone_list, do: Tzdata.zone_list() + @spec options(DateTime.t()) :: [{:key, String.t()}, {:value, String.t()}, {:offset, integer()}] def options(now \\ DateTime.utc_now()) do Tzdata.zone_list() diff --git a/lib/plausible_web/controllers/api/internal/annotations_controller.ex b/lib/plausible_web/controllers/api/internal/annotations_controller.ex new file mode 100644 index 000000000000..bbd1dac6fde9 --- /dev/null +++ b/lib/plausible_web/controllers/api/internal/annotations_controller.ex @@ -0,0 +1,149 @@ +defmodule PlausibleWeb.Api.Internal.AnnotationsController do + @moduledoc """ + Internal API controller for annotations. + """ + use Plausible + use PlausibleWeb, :controller + use PlausibleWeb.Plugs.ErrorHandler + alias Plausible.Annotations + alias Plausible.ChangesetHelpers + alias Plausible.Stats.{ApiQueryParser, QueryPeriod} + alias PlausibleWeb.Api.Helpers, as: H + + plug(PlausibleWeb.Plugs.FeatureFlagCheckPlug, [:annotations]) + + def index(conn, params) do + user = conn.assigns.current_user + site = conn.assigns.site + site_role = conn.assigns.site_role + + with {:ok, input_date_range} <- parse_input_date_range(params), + {:ok, relative_date} <- parse_relative_date(params) do + now = DateTime.utc_now(:second) + + range_in_site_tz = + QueryPeriod.build_range_for_site(input_date_range, site, relative_date, now) + + case Annotations.get_all_for_site(site, site_role, user, range_in_site_tz) do + {:ok, result} -> + json(conn, result) + + {:error, :not_enough_permissions} -> + json(conn, "Not enough permissions to get annotations") + end + else + {:error, message} -> H.bad_request(conn, message) + end + end + + defp parse_input_date_range(%{ + "date_range_start" => start, + "date_range_end" => end_ + }) + when is_binary(start) and is_binary(end_) do + case ApiQueryParser.parse_input_date_range([start, end_]) do + {:ok, parsed} -> {:ok, parsed} + {:error, %{message: message}} -> {:error, message} + end + end + + defp parse_input_date_range(%{"date_range" => "realtime"}), do: {:ok, :realtime} + defp parse_input_date_range(%{"date_range" => "realtime_30m"}), do: {:ok, :realtime_30m} + + defp parse_input_date_range(%{"date_range" => date_range}) do + case ApiQueryParser.parse_input_date_range(date_range) do + {:ok, parsed} -> {:ok, parsed} + {:error, %{message: message}} -> {:error, message} + end + end + + defp parse_input_date_range(_), do: {:error, "Required 'date_range' parameter missing"} + + defp parse_relative_date(%{"relative_date" => date}) when is_binary(date) do + case Date.from_iso8601(date) do + {:ok, date} -> {:ok, date} + _ -> {:error, "Failed to convert '#{date}' to date"} + end + end + + defp parse_relative_date(_), do: {:ok, nil} + + def create(conn, params) do + user = conn.assigns.current_user + site = conn.assigns.site + site_role = conn.assigns.site_role + + case Annotations.insert_one(user, site, site_role, params) do + {:error, :not_enough_permissions} -> + H.not_enough_permissions(conn, "Not enough permissions to create annotation") + + {:error, :annotations_limit_reached} -> + H.not_enough_permissions(conn, "Annotations limit reached") + + {:error, {:invalid_annotation, errors}} when is_list(errors) -> + conn + |> put_status(400) + |> json(%{ + error: ChangesetHelpers.serialize_first_error(errors) + }) + + {:ok, annotation} -> + json(conn, annotation) + end + end + + def update(conn, params) do + user = conn.assigns.current_user + site = conn.assigns.site + site_role = conn.assigns.site_role + annotation_id = normalize_annotation_id_param(params["annotation_id"]) + + case Annotations.update_one(user, site, site_role, annotation_id, params) do + {:error, :not_enough_permissions} -> + H.not_enough_permissions(conn, "Not enough permissions to edit annotation") + + {:error, :annotation_not_found} -> + annotation_not_found(conn, params["annotation_id"]) + + {:error, {:invalid_annotation, errors}} when is_list(errors) -> + conn + |> put_status(400) + |> json(%{ + error: ChangesetHelpers.serialize_first_error(errors) + }) + + {:ok, annotation} -> + json(conn, annotation) + end + end + + def delete(conn, params) do + user = conn.assigns.current_user + site = conn.assigns.site + site_role = conn.assigns.site_role + annotation_id = normalize_annotation_id_param(params["annotation_id"]) + + case Annotations.delete_one(user, site, site_role, annotation_id) do + {:error, :not_enough_permissions} -> + H.not_enough_permissions(conn, "Not enough permissions to delete annotation") + + {:error, :annotation_not_found} -> + annotation_not_found(conn, params["annotation_id"]) + + {:ok, annotation} -> + json(conn, annotation) + end + end + + @spec normalize_annotation_id_param(any()) :: nil | pos_integer() + defp normalize_annotation_id_param(input) do + case Integer.parse(input) do + {int_value, ""} when int_value > 0 -> int_value + _ -> nil + end + end + + defp annotation_not_found(%Plug.Conn{} = conn, annotation_id_param) do + H.not_found(conn, "Annotation not found with ID #{inspect(annotation_id_param)}") + end +end diff --git a/lib/plausible_web/controllers/api/internal/segments_controller.ex b/lib/plausible_web/controllers/api/internal/segments_controller.ex index 30e55c336c16..623ebf8788a1 100644 --- a/lib/plausible_web/controllers/api/internal/segments_controller.ex +++ b/lib/plausible_web/controllers/api/internal/segments_controller.ex @@ -6,6 +6,7 @@ defmodule PlausibleWeb.Api.Internal.SegmentsController do use PlausibleWeb, :controller use PlausibleWeb.Plugs.ErrorHandler alias PlausibleWeb.Api.Helpers, as: H + alias Plausible.ChangesetHelpers alias Plausible.Segments def create( @@ -29,7 +30,7 @@ defmodule PlausibleWeb.Api.Internal.SegmentsController do conn |> put_status(400) |> json(%{ - error: Segments.serialize_first_error(errors) + error: ChangesetHelpers.serialize_first_error(errors) }) {:ok, segment} -> @@ -63,7 +64,7 @@ defmodule PlausibleWeb.Api.Internal.SegmentsController do conn |> put_status(400) |> json(%{ - error: Segments.serialize_first_error(errors) + error: ChangesetHelpers.serialize_first_error(errors) }) {:ok, segment} -> diff --git a/lib/plausible_web/plugs/feature_flag_check_plug.ex b/lib/plausible_web/plugs/feature_flag_check_plug.ex new file mode 100644 index 000000000000..c04e23e38d33 --- /dev/null +++ b/lib/plausible_web/plugs/feature_flag_check_plug.ex @@ -0,0 +1,31 @@ +defmodule PlausibleWeb.Plugs.FeatureFlagCheckPlug do + @moduledoc """ + plug(PlausibleWeb.Plugs.FeatureFlagCheckPlug, [:flag_foo, :flag_bar]) + to halt any API connections with 404 where conn.assigns.current_user or conn.assigns.site + don't have both feature flags true. + """ + def init(feature_flags) when is_list(feature_flags) and length(feature_flags) > 0 do + feature_flags + end + + def init(_), + do: raise(ArgumentError, "The first argument must be a non-empty list of feature flags") + + def call(%Plug.Conn{} = conn, flags) do + if validate(conn.assigns[:current_user], conn.assigns.site, flags) do + conn + else + PlausibleWeb.Api.Helpers.not_found(conn, "Not found") + end + end + + defp validate(nil = _current_user, site, flags), + do: Enum.all?(flags, fn flag -> FunWithFlags.enabled?(flag, for: site) end) + + defp validate(current_user, site, flags), + do: + Enum.all?(flags, fn flag -> + FunWithFlags.enabled?(flag, for: current_user) || + FunWithFlags.enabled?(flag, for: site) + end) +end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index d91ed832055f..a6939bfef399 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -303,6 +303,14 @@ defmodule PlausibleWeb.Router do delete "/:segment_id", SegmentsController, :delete get "/:segment_id/shared-links", SegmentsController, :get_related_shared_links end + + scope "/:domain/annotations", PlausibleWeb.Api.Internal, + private: %{allow_consolidated_views: true} do + get "/", AnnotationsController, :index + post "/", AnnotationsController, :create + patch "/:annotation_id", AnnotationsController, :update + delete "/:annotation_id", AnnotationsController, :delete + end end scope "/api/v1/stats", PlausibleWeb.Api, diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index 633430e04a46..6a8058bb7d73 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -26,8 +26,9 @@ data-props-available={ to_string(Plausible.Billing.Feature.Props.check_availability(@site.team) == :ok) } - data-site-segments-available={ - to_string(Plausible.Billing.Feature.SiteSegments.check_availability(@site.team) == :ok) + data-site-segments-available={to_string(Plausible.Segments.site_segments_available?(@site))} + data-site-annotations-available={ + to_string(Plausible.Annotations.site_annotations_available?(@site)) } data-revenue-goals={Jason.encode!(@revenue_goals)} data-funnels={Jason.encode!(@funnels)} diff --git a/test/plausible/annotations/annotation_test.exs b/test/plausible/annotations/annotation_test.exs new file mode 100644 index 000000000000..07b30ecdef25 --- /dev/null +++ b/test/plausible/annotations/annotation_test.exs @@ -0,0 +1,163 @@ +defmodule Plausible.Annotations.AnnotationTest do + use ExUnit.Case, async: true + alias Plausible.Annotations.Annotation + + describe "changeset/3 for date granularity" do + for {dt, expected_errors} <- [ + {"2026-06-30T00:00:00", [date: {"must be supplied for chosen granularity", []}]}, + {"2026-06-30T00:00:00Z", [date: {"must be supplied for chosen granularity", []}]}, + {~U[2026-06-30 14:30:00Z], [date: {"must be supplied for chosen granularity", []}]}, + {"2026-07-05", [datetime: {"is invalid", [type: :utc_datetime, validation: :cast]}]}, + {~D[2026-07-06], [datetime: {"is invalid", [type: :utc_datetime, validation: :cast]}]} + ] do + test "rejects datetime #{dt} with appropriate error" do + changeset = + Annotation.changeset( + %Annotation{}, + %{ + note: "feature released", + type: "personal", + granularity: "date", + datetime: unquote(Macro.escape(dt)) + }, + "Etc/UTC" + ) + + assert changeset.errors == unquote(Macro.escape(expected_errors)) + end + end + + test "rejects invalid date value" do + changeset = + Annotation.changeset( + %Annotation{}, + %{ + note: "feature released", + type: "personal", + granularity: "date", + date: "2026-13-45" + }, + "Etc/UTC" + ) + + assert [date: {"is invalid", _}] = changeset.errors + end + + for {d, expected} <- [ + {"2026-06-30", ~U[2026-06-30 00:00:00Z]}, + {~D[2026-07-01], ~U[2026-07-01 00:00:00Z]} + ] do + test "accepts date #{d}, parsing it to that date at UTC midnight (#{expected})" do + changeset = + Annotation.changeset( + %Annotation{}, + %{ + note: "feature released", + type: "personal", + granularity: "date", + date: unquote(Macro.escape(d)) + }, + Enum.random(Plausible.Timezones.zone_list()) + ) + + assert changeset.valid? + assert changeset.changes.datetime == unquote(Macro.escape(expected)) + end + end + end + + describe "changeset/3 for minute granularity" do + for d <- ["2026-07-05", ~D[2026-07-06]] do + test "requires :datetime to be present, given date: #{d}" do + changeset = + Annotation.changeset( + %Annotation{}, + %{ + note: "feature released", + type: "personal", + granularity: "minute", + date: unquote(Macro.escape(d)) + }, + "Etc/UTC" + ) + + assert changeset.errors == [datetime: {"must be supplied for chosen granularity", []}] + end + end + + for {dt, tz, message} <- [ + {"2026-06-30", "Etc/UTC", "is invalid"}, + {~D[2026-07-01], "Etc/UTC", "is invalid"}, + {"invalid", "Etc/UTC", "is invalid"}, + {"2026-06-30T14:30:00", "Invalid/Timezone", + "cannot be parsed for the site timezone \"Invalid/Timezone\""} + ] do + test "rejects invalid :datetime, given #{dt} for timezone #{tz}" do + changeset = + Annotation.changeset( + %Annotation{}, + %{ + note: "feature released", + type: "personal", + granularity: "minute", + datetime: unquote(Macro.escape(dt)) + }, + unquote(tz) + ) + + assert [datetime: {unquote(message), _}] = changeset.errors + end + end + + for {dt, expected, tz} <- [ + {"2026-06-30T14:30:00Z", ~U[2026-06-30 14:30:00Z], + Enum.random(Plausible.Timezones.zone_list())}, + {"2026-06-30T10:00:00-02:00", ~U[2026-06-30 12:00:00Z], + Enum.random(Plausible.Timezones.zone_list())}, + {~U[2026-06-30 14:30:00Z], ~U[2026-06-30 14:30:00Z], + Enum.random(Plausible.Timezones.zone_list())}, + {"2026-06-30T14:30:00", ~U[2026-06-30 14:30:00Z], "Etc/UTC"}, + {"2026-06-30T14:30:00", ~U[2026-06-30 11:30:00Z], "Europe/Tallinn"} + ] do + test "accepts :datetime #{dt} and parses it to #{expected} UTC point in time for the site with timezone #{tz}" do + changeset = + Annotation.changeset( + %Annotation{}, + %{ + note: "feature released", + type: "personal", + granularity: "minute", + datetime: unquote(Macro.escape(dt)) + }, + unquote(tz) + ) + + assert changeset.valid? + assert changeset.changes.datetime == unquote(Macro.escape(expected)) + end + end + end + + describe "changeset/3 when both date and datetime are provided" do + for granularity <- ["date", "minute"] do + test "rejects both being set for #{granularity} granularity" do + changeset = + Annotation.changeset( + %Annotation{}, + %{ + note: "feature released", + type: "personal", + granularity: unquote(granularity), + date: "2026-06-30", + datetime: "2026-06-30T14:30:00Z" + }, + "Etc/UTC" + ) + + assert changeset.errors == [ + granularity: {"expects either date or datetime to be set", []} + ] + end + end + end +end diff --git a/test/plausible/billing/feature_test.exs b/test/plausible/billing/feature_test.exs index 82dfab0a4e42..c74e0e4cfa77 100644 --- a/test/plausible/billing/feature_test.exs +++ b/test/plausible/billing/feature_test.exs @@ -1,5 +1,4 @@ defmodule Plausible.Billing.FeatureTest do - alias Plausible.Billing.Feature.SiteSegments use Plausible.DataCase alias Plausible.Billing.Feature.{ diff --git a/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs b/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs new file mode 100644 index 000000000000..4821624af364 --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs @@ -0,0 +1,727 @@ +defmodule PlausibleWeb.Api.Internal.AnnotationsControllerTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + + describe "GET /api/:domain/annotations" do + setup [:create_user, :log_in] + + test "returns 400 when date_range is missing", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn = get(conn, "/api/#{site.domain}/annotations") + + assert %{"error" => _} = json_response(conn, 400) + end + + test "returns 400 when date_range is unrecognized", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn = get(conn, "/api/#{site.domain}/annotations?date_range=banana") + + assert %{"error" => _} = json_response(conn, 400) + end + + test "private site returns 404 for non-member", %{conn: conn} do + private_site = new_site() + + conn = + get( + conn, + "/api/#{private_site.domain}/annotations?date_range=day&relative_date=2026-01-04" + ) + + assert json_response(conn, 404) + end + + test "only site annotations are shown when viewing a public site, with owners info hidden", + %{conn: conn} do + site_owner = new_user() + public_site = new_site(public: true, owner: site_owner) + insert(:annotation, site: public_site, owner: site_owner, type: :site, note: "site note") + insert(:annotation, site: public_site, owner: site_owner, type: :personal, note: "private") + + conn = + get( + conn, + "/api/#{public_site.domain}/annotations?date_range=day&relative_date=2026-01-04" + ) + + assert_matches [ + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "site note", + "type" => "site", + "datetime" => "2026-01-04", + "granularity" => "date", + "owner_id" => nil, + "owner_name" => nil, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) + ] = json_response(conn, 200) + end + + test "personal annotations of the user and all site annotations are shown with annotation owner info when viewing their site, sorted by updated_at, ascending", + %{conn: conn, user: user} do + site = new_site() + other_user = new_user() + add_guest(site, user: user, role: :viewer) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + note: "mine", + inserted_at: ~U[2026-07-01 10:00:00Z], + updated_at: ~U[2026-07-01 10:00:00Z] + ) + + insert(:annotation, + site: site, + owner: other_user, + type: :personal, + note: "not mine", + inserted_at: ~U[2026-07-01 10:00:00Z], + updated_at: ~U[2026-07-01 11:00:00Z] + ) + + insert(:annotation, + site: site, + owner: other_user, + type: :site, + note: "shared", + inserted_at: ~U[2026-07-01 10:00:00Z], + updated_at: ~U[2026-07-01 12:00:00Z] + ) + + insert(:annotation, + site: site, + owner: nil, + type: :site, + note: "dangling", + inserted_at: ~U[2026-07-01 10:00:00Z], + updated_at: ~U[2026-07-01 13:00:00Z] + ) + + conn = get(conn, "/api/#{site.domain}/annotations?date_range=day&relative_date=2026-01-04") + + results = json_response(conn, 200) + + assert_matches [ + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "dangling", + "type" => "site", + "datetime" => "2026-01-04", + "granularity" => "date", + "owner_id" => nil, + "owner_name" => nil, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }), + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "shared", + "type" => "site", + "datetime" => "2026-01-04", + "granularity" => "date", + "owner_id" => ^other_user.id, + "owner_name" => ^other_user.name, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }), + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "mine", + "type" => "personal", + "datetime" => "2026-01-04", + "granularity" => "date", + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) + ] = results + end + + test "filters out annotations outside a 28d window", %{conn: conn, user: user} do + site = new_site(owner: user) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-26 00:00:00Z], + note: "in range" + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-05-01 00:00:00Z], + note: "out of range" + ) + + conn = + get(conn, "/api/#{site.domain}/annotations?date_range=28d&relative_date=2026-06-29") + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["in range"] + end + + test "filters by a custom [from,to] date_range", %{conn: conn, user: user} do + site = new_site(owner: user) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-22 00:00:00Z], + note: "in range" + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-26 00:00:00Z], + note: "out of range" + ) + + conn = + get( + conn, + "/api/#{site.domain}/annotations?date_range_start=2026-06-20&date_range_end=2026-06-25" + ) + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["in range"] + end + + test "filters minute granularity annotations to be within the UTC datetime range", + %{conn: conn, user: user} do + # NY is UTC-4 in June (DST). + # Full day of 2026-06-28 -> [~U[2026-06-28 04:00:00Z], ~U[2026-06-29 03:59:59]]. + site = new_site(owner: user, timezone: "America/New_York") + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + # 11:00 local — inside the UTC window + datetime: ~U[2026-06-28 15:00:00Z], + note: "in window" + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + # next-day 01:00 local — past the UTC window end + datetime: ~U[2026-06-29 05:00:00Z], + note: "past window" + ) + + conn = + get(conn, "/api/#{site.domain}/annotations?date_range=day&relative_date=2026-06-28") + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["in window"] + end + + test "filters date granularity annotations that are within the local date range", + %{conn: conn, user: user} do + site = new_site(owner: user, timezone: "Asia/Tokyo") + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-29 00:00:00Z], + note: "in range" + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-30 00:00:00Z], + note: "out of range" + ) + + conn = + get(conn, "/api/#{site.domain}/annotations?date_range=7d&relative_date=2026-06-30") + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["in range"] + end + + test "fetches annotations for realtime date_range", %{conn: conn, user: user} do + site = new_site(owner: user) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + datetime: DateTime.utc_now(), + note: "now" + ) + + conn = get(conn, "/api/#{site.domain}/annotations?date_range=realtime") + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["now"] + end + end + + describe "POST /api/:domain/annotations" do + setup [:create_user, :log_in, :create_site] + + test "rejects missing note", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "type" => "personal", + "granularity" => "date", + "datetime" => "2026-01-04" + }) + + assert json_response(conn, 400) == %{"error" => "note can't be blank"} + end + + test "rejects empty note", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "", + "type" => "personal", + "granularity" => "date", + "datetime" => "2026-01-04" + }) + + assert json_response(conn, 400) == %{"error" => "note can't be blank"} + end + + test "rejects note over 255 bytes", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => String.duplicate("a", 256), + "type" => "personal", + "granularity" => "date", + "datetime" => "2026-01-04" + }) + + assert json_response(conn, 400) == %{"error" => "note should be at most 255 byte(s)"} + end + + test "rejects missing datetime", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "date" + }) + + assert json_response(conn, 400) == %{ + "error" => "date must be supplied for chosen granularity" + } + end + + test "rejects unknown granularity", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "hour", + "datetime" => "2026-01-04T00:00:00Z" + }) + + assert json_response(conn, 400) == %{"error" => "granularity is invalid"} + end + + test "rejects missing granularity", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "datetime" => "2026-01-04T00:00:00Z" + }) + + assert json_response(conn, 400) == %{"error" => "granularity can't be blank"} + end + + test "rejects unknown type as insufficient permissions", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "team", + "granularity" => "date", + "datetime" => "2026-01-04" + }) + + assert json_response(conn, 403) == %{ + "error" => "Not enough permissions to create annotation" + } + end + + for {params, error} <- [ + { + %{"granularity" => "date", "datetime" => "2026-01-04T00:00:00Z"}, + "date must be supplied for chosen granularity" + }, + { + %{"granularity" => "date", "datetime" => "2026-01-04T00:00:00"}, + "date must be supplied for chosen granularity" + }, + { + %{"granularity" => "date", "date" => ""}, + "date must be supplied for chosen granularity" + }, + { + %{"granularity" => "date", "date" => "2026-1-4"}, + "date is invalid" + }, + { + %{"granularity" => "date", "date" => "2026-13-45"}, + "date is invalid" + }, + { + %{"granularity" => "date", "date" => "not-a-date"}, + "date is invalid" + }, + { + %{"granularity" => "minute", "datetime" => "2026-01-04"}, + "datetime is invalid" + }, + { + %{"granularity" => "minute", "datetime" => "not-a-datetime"}, + "datetime is invalid" + }, + { + %{"granularity" => "minute", "datetime" => ""}, + "datetime must be supplied for chosen granularity" + }, + { + %{"granularity" => "minute", "datetime" => "2026-13-45T14:30:00Z"}, + "datetime is invalid" + } + ] do + test "rejects #{inspect(params)} with error #{error}", %{conn: conn, site: site} do + conn = + post( + conn, + "/api/#{site.domain}/annotations", + Map.merge( + %{"note" => "feature released", "type" => "personal"}, + unquote(Macro.escape(params)) + ) + ) + + assert json_response(conn, 400) == %{"error" => unquote(error)} + end + end + + test "accepts bare date string when granularity is date", + %{conn: conn, site: site} do + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "date", + "date" => "2026-01-04" + }) + |> json_response(200) + + assert_matches ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "feature released", + "type" => "personal", + "granularity" => "date", + "datetime" => "2026-01-04", + "owner_id" => ^any(:pos_integer), + "owner_name" => ^any(:string), + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + end + + test "accepts full datetime string when granularity is minute", + %{conn: conn, site: site} do + # Site is Etc/UTC by default; UTC moment is returned as naive local time + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "minute", + "datetime" => "2026-01-04T14:32:00Z" + }) + |> json_response(200) + + assert response["datetime"] == "2026-01-04T14:32:00" + end + + test "converts naive local time to UTC and returns it back as local time", + %{conn: conn, user: user} do + # America/New_York is UTC-5 in January; input and output should be the same + # local time (round-trip), while the DB stores the UTC equivalent. + site = new_site(owner: user, timezone: "America/New_York") + + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "minute", + "datetime" => "2026-01-04T14:30:00" + }) + |> json_response(200) + + assert response["datetime"] == "2026-01-04T14:30:00" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, response["id"]) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-01-04 19:30:00Z] + end + + test "UTC datetime string is stored as UTC and returned as site local time", + %{conn: conn, user: user} do + # America/New_York is UTC-5 in January, so 14:30 UTC = 09:30 local + site = new_site(owner: user, timezone: "America/New_York") + + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "minute", + "datetime" => "2026-01-04T14:30:00Z" + }) + |> json_response(200) + + assert response["datetime"] == "2026-01-04T09:30:00" + end + end + + describe "PATCH /api/:domain/annotations/:annotation_id" do + setup [:create_user, :log_in, :create_site] + + test "rejects granularity change from date to minute without a new datetime", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-15 00:00:00Z] + ) + + conn = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "minute" + }) + + assert json_response(conn, 400) == %{ + "error" => "datetime must be supplied for chosen granularity" + } + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :date + assert reloaded.datetime == ~U[2026-06-15 00:00:00Z] + end + + test "rejects granularity change from minute to date without a new datetime", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + datetime: ~U[2026-06-15 14:30:00Z] + ) + + conn = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "date" + }) + + assert json_response(conn, 400) == %{ + "error" => "date must be supplied for chosen granularity" + } + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-06-15 14:30:00Z] + end + + test "accepts granularity change from date to minute with a new datetime", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-15 00:00:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "minute", + "datetime" => "2026-06-15T14:30:00Z" + }) + |> json_response(200) + + assert response["granularity"] == "minute" + assert response["datetime"] == "2026-06-15T14:30:00" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-06-15 14:30:00Z] + end + + test "accepts granularity change from minute to date with a new datetime", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + datetime: ~U[2026-06-15 14:30:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "date", + "date" => "2026-06-20" + }) + |> json_response(200) + + assert response["granularity"] == "date" + assert response["datetime"] == "2026-06-20" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :date + assert reloaded.datetime == ~U[2026-06-20 00:00:00Z] + end + + test "converts naive local time to UTC and returns it back as local time", + %{conn: conn, user: user} do + # America/New_York is UTC-4 in June (DST), so input and output are the same + site = new_site(owner: user, timezone: "America/New_York") + + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + datetime: ~U[2026-01-01 00:00:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "minute", + "datetime" => "2026-06-15T10:00:00" + }) + |> json_response(200) + + assert response["datetime"] == "2026-06-15T10:00:00" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-06-15 14:00:00Z] + end + + test "accepts bare date string when granularity is date", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-01-01 00:00:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "date", + "date" => "2026-06-15" + }) + |> json_response(200) + + assert response["datetime"] == "2026-06-15" + end + + for {casename, note_has_owner?} <- [ + {"dangling site note", false}, + {"site note left by other user", true} + ] do + test "users with site note permissions can edit a #{casename}, becoming its new owner", + %{ + conn: conn, + site: site, + user: user + } do + note_owner = + if(unquote(note_has_owner?), + do: + site.team + |> add_member( + user: new_user(name: "other user"), + role: :editor + ), + else: nil + ) + + annotation = + insert(:annotation, + owner: note_owner, + site: site, + type: :site, + granularity: :date, + datetime: "2026-06-01T00:00:00Z" + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "note" => "updated" + }) + |> json_response(200) + + assert_matches ^strict_map(%{ + "id" => ^annotation.id, + "note" => "updated", + "type" => + ^any( + :string, + ~r/#{annotation.type}/ + ), + "granularity" => + ^any( + :string, + ~r/#{annotation.granularity}/ + ), + "datetime" => "2026-06-01", + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "inserted_at" => + ^any( + :string, + ~r/#{Calendar.strftime(annotation.inserted_at, "%Y-%m-%dT%H:%M:%S")}/ + ), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + end + end + end +end diff --git a/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs b/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs index e73d58a3cf67..ddbf41a4c505 100644 --- a/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs +++ b/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs @@ -358,44 +358,58 @@ defmodule PlausibleWeb.Api.Internal.SegmentsControllerTest do }) = response end - test "the last editor of a dangling site segment (segment with owner_id: nil) becomes the new owner", - %{ - conn: conn, - user: user - } do - site = new_site() - add_guest(site, user: user, role: :editor) + for {casename, segment_has_owner?} <- [ + {"dangling site segment", false}, + {"site segment by other user", true} + ] do + test "the last editor of a #{casename} becomes the new owner", + %{ + conn: conn, + site: site, + user: user + } do + segment_owner = + if(unquote(segment_has_owner?), + do: + site.team + |> add_member( + user: new_user(name: "other user"), + role: :editor + ), + else: nil + ) - segment = - insert(:segment, - site: site, - name: "original name", - type: :site, - owner: nil - ) + segment = + insert(:segment, + site: site, + name: "original name", + type: :site, + owner: segment_owner + ) - response = - patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{"name" => "updated name"}) - |> json_response(200) + response = + patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{"name" => "updated name"}) + |> json_response(200) - assert_matches ^strict_map(%{ - "id" => ^segment.id, - "name" => "updated name", - "type" => - ^any( - :string, - ~r/#{segment.type}/ - ), - "owner_id" => ^user.id, - "owner_name" => ^user.name, - "segment_data" => ^segment.segment_data, - "inserted_at" => - ^any( - :string, - ~r/#{Calendar.strftime(segment.inserted_at, "%Y-%m-%dT%H:%M:%S")}/ - ), - "updated_at" => ^any(:iso8601_naive_datetime) - }) = response + assert_matches ^strict_map(%{ + "id" => ^segment.id, + "name" => "updated name", + "type" => + ^any( + :string, + ~r/#{segment.type}/ + ), + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "segment_data" => ^segment.segment_data, + "inserted_at" => + ^any( + :string, + ~r/#{Calendar.strftime(segment.inserted_at, "%Y-%m-%dT%H:%M:%S")}/ + ), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + end end test "cannot move segment to another site by passing site_id param", %{ @@ -403,19 +417,19 @@ defmodule PlausibleWeb.Api.Internal.SegmentsControllerTest do user: user, site: site } do - victim_site = new_site() + other_site = new_site() segment = insert(:segment, site: site, owner: user, type: :personal, name: "test segment") patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{ "name" => "updated name", "type" => "personal", "segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]], "labels" => %{}}, - "site_id" => victim_site.id + "site_id" => other_site.id }) reloaded = Repo.reload!(segment) assert reloaded.site_id == site.id - assert reloaded.site_id != victim_site.id + assert reloaded.site_id != other_site.id end end diff --git a/test/plausible_web/plugs/feature_flag_check_plug_test.exs b/test/plausible_web/plugs/feature_flag_check_plug_test.exs new file mode 100644 index 000000000000..ead037ed4b9d --- /dev/null +++ b/test/plausible_web/plugs/feature_flag_check_plug_test.exs @@ -0,0 +1,46 @@ +defmodule PlausibleWeb.Plugs.FeatureFlagCheckPlugTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Teams.Test + alias PlausibleWeb.Plugs.{AuthorizeSiteAccess, FeatureFlagCheckPlug} + + setup [:create_user, :log_in, :create_site] + + test "returns 404 when any of the expected feature flags is not enabled", %{ + conn: conn, + site: site + } do + # currently enabled flags are defined test/test_helper.exs + required_flags = + FeatureFlagCheckPlug.init([:missing_feature_flag, :annotations]) + + conn = + conn + |> get_conn_with_current_user_and_site(site) + |> FeatureFlagCheckPlug.call(required_flags) + + assert conn.halted + assert %{"error" => "Not found"} == json_response(conn, 404) + end + + test "passes conn when required flags are enabled", %{ + conn: conn, + site: site + } do + # currently enabled flags are defined test/test_helper.exs + required_flags = FeatureFlagCheckPlug.init([:annotations]) + + conn = + conn + |> get_conn_with_current_user_and_site(site) + |> FeatureFlagCheckPlug.call(required_flags) + + assert !conn.halted + end + + defp get_conn_with_current_user_and_site(%Plug.Conn{} = conn, %Plausible.Site{} = site) do + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/api-basic?site=#{site.domain}") + |> AuthorizeSiteAccess.call(AuthorizeSiteAccess.init({:all_roles, "site"})) + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 5396ee4475d2..a70c8f9b9ba0 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -1,4 +1,7 @@ defmodule Plausible.Factory do + @moduledoc """ + See https://ecto.hexdocs.pm/test-factories.html + """ use ExMachina.Ecto, repo: Plausible.Repo require Plausible.Billing.Subscription.Status alias Plausible.Billing.Subscription @@ -392,6 +395,16 @@ defmodule Plausible.Factory do } end + def annotation_factory do + %Plausible.Annotations.Annotation{ + note: "a test annotation", + type: :site, + datetime: ~U[2026-01-04 00:00:00Z], + date: ~D[2026-01-04], + granularity: :date + } + end + defp hash_key() do Keyword.fetch!( Application.get_env(:plausible, PlausibleWeb.Endpoint), diff --git a/test/test_helper.exs b/test/test_helper.exs index bd6ac0e85ed4..63f401e79122 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -13,6 +13,8 @@ if Mix.env() != :e2e_test do Application.ensure_all_started(:double) + FunWithFlags.enable(:annotations) + Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual) end