Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
49a3f33
Annotations BE
apata Jun 17, 2026
31f39a4
Block access to annotations API without FF
apata Jul 2, 2026
fbc1298
Fix spelling
apata Jul 2, 2026
76098ee
Fix unused alias
apata Jul 2, 2026
432c65e
Add moduledoc to factory.ex
apata Jul 2, 2026
8f693f1
Fix spec
apata Jul 2, 2026
2d27014
Pass full structs to annotation API and don't validate how Phoenix ca…
zoldar Jul 6, 2026
b4615be
Refactor permission checking and remove arg matches in typed API funcs
zoldar Jul 6, 2026
f0dc0f4
Move localising annotation to JSON serialisation stage
zoldar Jul 6, 2026
70cd3e6
Refactor annotations to use different time fields depending on granul…
zoldar Jul 6, 2026
7d03e29
Move naive datetime coercion inside changeset
zoldar Jul 6, 2026
97abedf
Extract filtering by range to a function
zoldar Jul 6, 2026
3881fae
Refactor and fix error typespecs
zoldar Jul 6, 2026
e8eb66f
Add plug requiring user in actions other than index
zoldar Jul 7, 2026
5df53eb
Replace ad-hoc plug with checks on Annotations API level and clean up…
zoldar Jul 7, 2026
3df590d
Fix annotation owner update and add a provisional test
zoldar Jul 7, 2026
2459985
Move where FF plug is called
apata Jul 8, 2026
cdbcc98
Make annotation_factory a bit more realistic
apata Jul 8, 2026
2b852f6
Simplify annotation tests, add cases
apata Jul 8, 2026
579c601
Fix issue with reassigning note owner
apata Jul 8, 2026
285ab55
Stop using any_timezone() helper
apata Jul 8, 2026
25634e8
Merge remote-tracking branch 'origin/master' into annotations/be
apata Jul 8, 2026
fcd5c4e
Fix issue with dangling site notes shown to public role but not shown…
apata Jul 8, 2026
7542f6e
Handle failure to convert naive datetime to UTC for invalid timezone
apata Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions lib/plausible/annotations/annotation.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading