defmodule MvWeb.MemberLive.Index.FieldSelection do @moduledoc """ Handles user-specific field selection persistence and URL parameter parsing. This module manages: - Reading/writing field selection from cookies (persistent storage) - Reading/writing field selection from session (temporary storage) - Parsing field selection from URL parameters - Merging multiple sources with priority: URL > Session > Cookie ## Data Format Field selection is stored as a map: ```elixir %{ "first_name" => true, "email" => true, "street" => false, "custom_field_abc-123" => true } ``` ## Cookie/Session Format Stored as JSON string: `{"first_name":true,"email":true}` ## URL Format Comma-separated list: `?fields=first_name,email,custom_field_abc-123` """ @cookie_name "member_field_selection" @cookie_max_age 365 * 24 * 60 * 60 @session_key "member_field_selection" @doc """ Reads field selection from session. Returns a map of field names (strings) to boolean visibility values. Returns empty map if no selection is stored. """ @spec get_from_session(map()) :: %{String.t() => boolean()} def get_from_session(session) when is_map(session) do case Map.get(session, @session_key) do nil -> %{} json_string when is_binary(json_string) -> parse_json(json_string) _ -> %{} end end def get_from_session(_), do: %{} @doc """ Saves field selection to session. Converts the map to JSON string and stores it in the session. """ @spec save_to_session(map(), %{String.t() => boolean()}) :: map() def save_to_session(session, selection) when is_map(selection) do json_string = Jason.encode!(selection) Map.put(session, @session_key, json_string) end def save_to_session(session, _), do: session @doc """ Reads field selection from cookie. Returns a map of field names (strings) to boolean visibility values. Returns empty map if no cookie is present. Note: This function requires the connection to have cookies parsed. In LiveView, cookies are typically accessed via get_connect_info. """ @spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()} def get_from_cookie(conn) do case Plug.Conn.get_req_header(conn, "cookie") do nil -> %{} cookie_header -> # Parse cookies manually from header cookies = parse_cookie_header(cookie_header) case Map.get(cookies, @cookie_name) do nil -> %{} json_string when is_binary(json_string) -> parse_json(json_string) _ -> %{} end end end # Parses cookie header string into a map defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do cookie_header |> String.split(";") |> Enum.map(&String.trim/1) |> Enum.map(&String.split(&1, "=", parts: 2)) |> Enum.reduce(%{}, fn [key, value], acc -> Map.put(acc, key, URI.decode(value)) [key], acc -> Map.put(acc, key, "") _, acc -> acc end) end defp parse_cookie_header(_), do: %{} @doc """ Saves field selection to cookie. Sets a persistent cookie with the field selection as JSON. """ @spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t() def save_to_cookie(conn, selection) when is_map(selection) do json_string = Jason.encode!(selection) secure = Application.get_env(:mv, :use_secure_cookies, false) Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string, max_age: @cookie_max_age, same_site: "Lax", http_only: true, secure: secure ) end def save_to_cookie(conn, _), do: conn @doc """ Parses field selection from URL parameters. Expects a comma-separated list of field names in the `fields` parameter. All fields in the list are set to `true` (visible). ## Examples iex> parse_from_url(%{"fields" => "first_name,email"}) %{"first_name" => true, "email" => true} iex> parse_from_url(%{"fields" => "custom_field_abc-123"}) %{"custom_field_abc-123" => true} iex> parse_from_url(%{}) %{} """ @spec parse_from_url(map()) :: %{String.t() => boolean()} def parse_from_url(params) when is_map(params) do case Map.get(params, "fields") do nil -> %{} "" -> %{} fields_string when is_binary(fields_string) -> parse_fields_string(fields_string) _ -> %{} end end def parse_from_url(_), do: %{} @doc """ Merges multiple field selection sources with priority. Priority order (highest to lowest): 1. URL parameters 2. Session 3. Cookie Later sources override earlier ones for the same field. ## Examples iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true}) %{"first_name" => true, "email" => true, "street" => true} iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{}) %{"first_name" => false} # URL has priority """ @spec merge_sources( %{String.t() => boolean()}, %{String.t() => boolean()}, %{String.t() => boolean()} ) :: %{String.t() => boolean()} def merge_sources(url_selection, session_selection, cookie_selection) do %{} |> Map.merge(cookie_selection) |> Map.merge(session_selection) |> Map.merge(url_selection) end @doc """ Converts field selection map to URL parameter string. Returns a comma-separated string of visible fields (where value is `true`). ## Examples iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false}) "first_name,email" """ @spec to_url_param(%{String.t() => boolean()}) :: String.t() def to_url_param(selection) when is_map(selection) do selection |> Enum.filter(fn {_field, visible} -> visible end) |> Enum.map(fn {field, _visible} -> field end) |> Enum.join(",") end def to_url_param(_), do: "" # Parses a JSON string into a map, handling errors gracefully defp parse_json(json_string) when is_binary(json_string) do case Jason.decode(json_string) do {:ok, decoded} when is_map(decoded) -> # Ensure all values are booleans Enum.reduce(decoded, %{}, fn {key, value}, acc when is_boolean(value) -> Map.put(acc, key, value) {key, _value}, acc -> Map.put(acc, key, true) end) _ -> %{} end end defp parse_json(_), do: %{} # Parses a comma-separated string of field names defp parse_fields_string(fields_string) do fields_string |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.filter(&(&1 != "")) |> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end) end end