defmodule WhoNeedHelp.Pagination do @moduledoc """ Small keyset-pagination primitives shared by database-backed lists. Cursors contain only the final row's UTC timestamp and UUID. They are validated before being used as query parameters and carry no authorization decision. """ @default_limit 24 @maximum_limit 100 defmodule Page do @moduledoc false defstruct entries: [], next_cursor: nil end def limit(options, default \\ @default_limit) do options |> Keyword.get(:limit, default) |> normalize_limit(default) end def cursor(options) do case decode(Keyword.get(options, :after)) do {:ok, cursor} -> cursor :error -> nil end end def page(rows, limit, cursor_fields) when is_function(cursor_fields, 1) do entries = Enum.take(rows, limit) next_cursor = if length(rows) > limit do entries |> List.last() |> cursor_fields.() |> then(fn {timestamp, id} -> encode(timestamp, id) end) end %Page{entries: entries, next_cursor: next_cursor} end def encode(%DateTime{} = timestamp, id) do %{"at" => DateTime.to_iso8601(timestamp), "id" => to_string(id)} |> Jason.encode!() |> Base.url_encode64(padding: false) end def decode(nil), do: {:ok, nil} def decode(""), do: {:ok, nil} def decode(encoded) when is_binary(encoded) do with {:ok, json} <- Base.url_decode64(encoded, padding: false), {:ok, %{"at" => timestamp, "id" => id}} <- Jason.decode(json), {:ok, timestamp, 0} <- DateTime.from_iso8601(timestamp), {:ok, id} <- Ecto.UUID.cast(id) do {:ok, {timestamp, id}} else _ -> :error end end def decode(_encoded), do: :error defp normalize_limit(limit, _default) when is_integer(limit) and limit > 0 and limit <= @maximum_limit, do: limit defp normalize_limit(_limit, default), do: min(default, @maximum_limit) end