File size: 7,517 Bytes
3e21b19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
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