defmodule SyntropyWeb.Api.Listing do @moduledoc """ Shared helpers for paginated, filterable list endpoints. List endpoints accept `limit`, `cursor`, `q`, and resource-specific filter params. When persistence is enabled the controllers page through Postgres; otherwise these helpers paginate the bounded in-memory rings with the same opaque-cursor contract. """ alias Syntropy.Persistence alias SyntropyWeb.Api.ControllerHelpers @type page :: Persistence.page() @filter_params %{status: "status", kind: "kind", q: "q", perspective: "perspective"} @doc """ Parses shared list params (`limit`, `cursor`, plus the allowed filters). Returns `{:error, :invalid_cursor}` when a cursor is present but malformed. """ @spec parse_opts(map(), [atom()]) :: {:ok, keyword()} | {:error, :invalid_cursor} def parse_opts(params, allowed_filters) do base = [limit: ControllerHelpers.limit(params)] filters = Enum.reduce(allowed_filters, base, fn filter, acc -> case Map.get(params, Map.fetch!(@filter_params, filter)) do value when is_binary(value) and value != "" -> Keyword.put(acc, filter, value) _other -> acc end end) case Map.get(params, "cursor") do nil -> {:ok, filters} cursor -> case Persistence.decode_cursor(cursor) do {:ok, decoded} -> {:ok, Keyword.put(filters, :cursor, decoded)} :error -> {:error, :invalid_cursor} end end end @doc """ Applies cursor + limit pagination to an already-filtered in-memory list. Entries must be sorted newest-first. `timestamp_key` selects the field used for cursor ordering; the entry `:id` (a string) breaks ties. """ @spec paginate(list(map()), keyword(), atom()) :: page() def paginate(entries, opts, timestamp_key) do limit = Keyword.get(opts, :limit, 100) remaining = case Keyword.get(opts, :cursor) do nil -> entries cursor -> Enum.filter(entries, &before_cursor?(&1, cursor, timestamp_key)) end page_entries = Enum.take(remaining, limit) has_more = length(remaining) > limit next_cursor = case {has_more, List.last(page_entries)} do {true, %{} = last} -> Persistence.encode_cursor({sort_timestamp(last, timestamp_key), last.id}) _other -> nil end %{entries: page_entries, has_more: has_more, next_cursor: next_cursor} end @doc """ Filters in-memory run envelopes by `:status`, `:kind`, and `:q`. """ @spec filter_runs(list(map()), keyword()) :: list(map()) def filter_runs(runs, opts) do runs |> filter_equal(:status, opts[:status]) |> filter_equal(:kind, opts[:kind]) |> filter_query(opts[:q], fn run -> [Map.get(run, :id), Map.get(run, :prompt)] end) end @doc """ Filters in-memory snapshots by `:kind` (trigger kind) and `:q`. """ @spec filter_snapshots(list(map()), keyword()) :: list(map()) def filter_snapshots(snapshots, opts) do snapshots |> filter_equal(:trigger_kind, opts[:kind]) |> filter_query(opts[:q], fn snapshot -> [Map.get(snapshot, :id), Map.get(snapshot, :task_id)] end) end @doc """ Filters in-memory recommendations by `:status`, `:kind`, and `:q`. """ @spec filter_recommendations(list(map()), keyword()) :: list(map()) def filter_recommendations(recommendations, opts) do recommendations |> filter_equal(:status, opts[:status]) |> filter_equal(:kind, opts[:kind]) |> filter_query(opts[:q], fn recommendation -> [ Map.get(recommendation, :id) | Map.get(recommendation, :candidate_agent_ids) || [] ] end) end @doc """ Filters agent exports by `:perspective` and `:q` (id, name, or perspective). """ @spec filter_agents(list(map()), keyword()) :: list(map()) def filter_agents(agents, opts) do agents |> filter_equal(:perspective, opts[:perspective]) |> filter_query(opts[:q], fn agent -> [ Map.get(agent, :id), Map.get(agent, :runtime_id), Map.get(agent, :name), Map.get(agent, :perspective) ] end) end @doc """ Builds the `meta` object appended to paginated list responses. """ @spec meta(page()) :: map() def meta(page) do %{has_more: page.has_more, next_cursor: page.next_cursor} end defp filter_equal(entries, _key, nil), do: entries defp filter_equal(entries, key, value) do Enum.filter(entries, &(Map.get(&1, key) == value)) end defp filter_query(entries, nil, _fields_fun), do: entries defp filter_query(entries, "", _fields_fun), do: entries defp filter_query(entries, q, fields_fun) do needle = String.downcase(q) Enum.filter(entries, fn entry -> entry |> fields_fun.() |> Enum.any?(fn value when is_binary(value) -> String.contains?(String.downcase(value), needle) _other -> false end) end) end defp before_cursor?(entry, {cursor_timestamp, cursor_id}, timestamp_key) do timestamp = sort_timestamp(entry, timestamp_key) case DateTime.compare(timestamp, cursor_timestamp) do :lt -> true :eq -> to_string(Map.get(entry, :id)) < cursor_id :gt -> false end end defp sort_timestamp(entry, timestamp_key) do case Map.get(entry, timestamp_key) do %DateTime{} = timestamp -> timestamp _other -> DateTime.from_unix!(0) end end end