defmodule WandererApp.Map do @moduledoc """ Represents the map structure and exposes actions that can be taken to update it """ import Ecto.Query require Logger @map_state_cache :map_state_cache # Default plan indicates no active subscription (free tier) @default_subscription_plan :alpha defstruct map_id: nil, name: nil, scope: :none, scopes: nil, owner_id: nil, characters: [], systems: Map.new(), hubs: [], connections: Map.new(), acls: [], options: Map.new(), characters_limit: nil, hubs_limit: nil, sse_enabled: false, webhooks_enabled: false, subscription_plan: @default_subscription_plan def new( %{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs} = input ) do # Extract the new scopes array field if present (nil if not set) scopes = Map.get(input, :scopes) # Extract SSE/webhooks settings (default to false if not present) sse_enabled = Map.get(input, :sse_enabled, false) webhooks_enabled = Map.get(input, :webhooks_enabled, false) map = struct!(__MODULE__, map_id: map_id, scope: scope, scopes: scopes, owner_id: owner_id, name: name, acls: acls, hubs: hubs, sse_enabled: sse_enabled, webhooks_enabled: webhooks_enabled ) update_map(map_id, map) map end def get_map(map_id) do case Cachex.get(:map_cache, map_id) do {:ok, nil} -> {:error, :not_found} {:ok, map} -> {:ok, map} end end def get_map!(map_id) do case get_map(map_id) do {:ok, map} -> map error -> Logger.error("Failed to get map #{map_id}: #{inspect(error)}") %{} end end def update_map(map_id, map_update) do Cachex.get_and_update(:map_cache, map_id, fn map -> case map do nil -> {:commit, map_update} _ -> {:commit, Map.merge(map, map_update)} end end) end def get_map_state(map_id, init_if_empty? \\ true) do case Cachex.get(@map_state_cache, map_id) do {:ok, nil} -> case init_if_empty? do true -> map_state = WandererApp.Map.Server.Impl.do_init_state(map_id: map_id) Cachex.put(@map_state_cache, map_id, map_state) {:ok, map_state} _ -> {:ok, nil} end {:ok, map_state} -> {:ok, map_state} end end def get_map_state!(map_id) do case get_map_state(map_id) do {:ok, map_state} -> map_state _ -> Logger.error("Failed to get map_state #{map_id}") throw("Failed to get map_state #{map_id}") end end def update_map_state(map_id, state_update), do: Cachex.get_and_update(@map_state_cache, map_id, fn map_state -> case map_state do nil -> new_state = WandererApp.Map.Server.Impl.do_init_state(map_id: map_id) {:commit, Map.merge(new_state, state_update)} _ -> {:commit, Map.merge(map_state, state_update)} end end) def delete_map_state(map_id), do: Cachex.del(@map_state_cache, map_id) def get_characters_limit(map_id), do: {:ok, map_id |> get_map!() |> Map.get(:characters_limit, 50)} def get_hubs_limit(map_id), do: {:ok, map_id |> get_map!() |> Map.get(:hubs_limit, 20)} def is_subscription_active?(map_id), do: is_subscription_active?(map_id, WandererApp.Env.map_subscriptions_enabled?()) def is_subscription_active?(_map_id, false), do: {:ok, true} def is_subscription_active?(map_id, _map_subscriptions_enabled) do {:ok, %{plan: plan}} = WandererApp.Map.SubscriptionManager.get_active_map_subscription(map_id) {:ok, plan != @default_subscription_plan} end def get_options(map_id), do: {:ok, map_id |> get_map!() |> Map.get(:options, Map.new())} def get_tracked_character_ids(map_id) do {:ok, map_id |> get_map!() |> Map.get(:characters, []) |> Enum.filter(fn character_id -> {:ok, tracking_start_time} = WandererApp.Cache.lookup( "character:#{character_id}:map:#{map_id}:tracking_start_time", nil ) not is_nil(tracking_start_time) end)} end @doc """ Returns a full list of characters in the map """ def list_characters(map_id), do: map_id |> get_map!() |> Map.get(:characters, []) |> Enum.map(fn character_id -> WandererApp.Character.get_map_character!(map_id, character_id) end) def list_systems(map_id), do: {:ok, map_id |> get_map!() |> Map.get(:systems, Map.new()) |> Map.values()} def list_systems!(map_id) do {:ok, systems} = list_systems(map_id) systems end def list_hubs(map_id) do {:ok, map} = map_id |> get_map() {:ok, map |> Map.get(:hubs, [])} end def list_hubs(map_id, hubs) do {:ok, _map} = map_id |> get_map() {:ok, hubs} end def list_connections(map_id), do: {:ok, map_id |> get_map!() |> Map.get(:connections, Map.new()) |> Map.values()} def list_connections!(map_id) do {:ok, connections} = list_connections(map_id) connections end def get_connection(map_id, solar_system_source, solar_system_target), do: map_id |> get_map!() |> Map.get(:connections, Map.new()) |> Map.get("#{solar_system_source}_#{solar_system_target}") def add_characters!(map, []), do: map def add_characters!(%{map_id: map_id} = map, characters) when is_list(characters) do # Get current characters list once current_characters = Map.get(map, :characters, []) characters_ids = characters |> Enum.map(fn %{character_id: char_id} -> char_id end) # Filter out characters that already exist new_character_ids = characters_ids |> Enum.reject(fn char_id -> char_id in current_characters end) # If all characters already exist, return early if new_character_ids == [] do map else case update_map(map_id, %{characters: new_character_ids ++ current_characters}) do {:commit, map} -> map _ -> map end end end def add_character( map_id, %{ id: character_id } = _character ) do characters = map_id |> get_map!() |> Map.get(:characters, []) case not (characters |> Enum.member?(character_id)) do true -> map_id |> update_map(%{characters: [character_id | characters]}) :ok _ -> :ok end end def get_system_characters(map_id, solar_system_id), do: map_id |> list_characters() |> filter(%{solar_system_id: solar_system_id}, match: :any) @doc """ Removes a character with a given id """ def remove_character(map_id, character_id) do characters = map_id |> get_map!() |> Map.get(:characters, []) case characters |> Enum.member?(character_id) do true -> map_id |> update_map(%{characters: characters |> Enum.reject(fn id -> id == character_id end)}) :ok _ -> :ok end end def check_location(map_id, location) do case find_system_by_location(map_id, location) do nil -> {:ok, location} %{} -> {:error, :already_exists} end end def find_system_by_location(_map_id, nil), do: nil def find_system_by_location(map_id, %{solar_system_id: solar_system_id} = _location) do case map_id |> get_map!() |> Map.get(:systems, Map.new()) |> Map.get(solar_system_id) do nil -> nil %{visible: true} = system -> system _system -> nil end end def check_connection( map_id, %{solar_system_id: solar_system_id} = _location, %{solar_system_id: old_solar_system_id} = _old_location ) do case map_id |> get_map!() |> Map.get(:connections, Map.new()) |> is_connection_exist?(%{ solar_system_source: solar_system_id, solar_system_target: old_solar_system_id }) do true -> {:error, :already_exists} _ -> :ok end end def update_subscription_settings!(%{map_id: map_id} = _map, subscription_settings) do characters_limit = Map.get(subscription_settings, :characters_limit) hubs_limit = Map.get(subscription_settings, :hubs_limit) plan = Map.get(subscription_settings, :plan, @default_subscription_plan) map_id |> update_map(%{ characters_limit: characters_limit, hubs_limit: hubs_limit, subscription_plan: plan }) map_id |> get_map!() end def update_options!(%{map_id: map_id} = _map, options) do map_id |> update_map(%{options: options}) map_id |> get_map!() end @doc """ Updates SSE enabled setting in the map cache. Called when the map's sse_enabled setting changes. """ def update_sse_enabled(map_id, sse_enabled) when is_binary(map_id) and is_boolean(sse_enabled) do update_map(map_id, %{sse_enabled: sse_enabled}) :ok end @doc """ Updates webhooks enabled setting in the map cache. Called when the map's webhooks_enabled setting changes. """ def update_webhooks_enabled(map_id, webhooks_enabled) when is_binary(map_id) and is_boolean(webhooks_enabled) do update_map(map_id, %{webhooks_enabled: webhooks_enabled}) :ok end @doc """ Checks if SSE is enabled for a map using the cache. Falls back to DB query if map is not in cache. Returns a boolean (defaults to false if map not found). """ def sse_enabled?(map_id) do case get_map(map_id) do {:ok, map} -> Map.get(map, :sse_enabled, false) {:error, :not_found} -> # Cache miss - fall back to DB case WandererApp.Api.Map.by_id(map_id) do {:ok, db_map} -> db_map.sse_enabled _ -> false end end end @doc """ Checks if SSE is enabled for a map with explicit not_found handling. Returns {:ok, boolean} or {:error, :not_found}. """ def sse_enabled_with_status(map_id) do case get_map(map_id) do {:ok, map} -> {:ok, Map.get(map, :sse_enabled, false)} {:error, :not_found} -> # Cache miss - fall back to DB case WandererApp.Api.Map.by_id(map_id) do {:ok, db_map} -> {:ok, db_map.sse_enabled} _ -> {:error, :not_found} end end end @doc """ Checks if webhooks are enabled for a map using the cache. Falls back to DB query if map is not in cache. """ def webhooks_enabled?(map_id) do case get_map(map_id) do {:ok, map} -> Map.get(map, :webhooks_enabled, false) {:error, :not_found} -> # Cache miss - fall back to DB case WandererApp.Api.Map.by_id(map_id) do {:ok, db_map} -> db_map.webhooks_enabled _ -> false end end end @doc """ Checks if subscription is active for a map using the cache. Returns {:ok, true} if active, {:ok, false} if not, or {:error, :not_cached} if not in cache. Note: In CE mode (subscriptions disabled), use is_subscription_active?/1 which handles this case without cache lookup. """ def subscription_active_cached?(map_id) do case get_map(map_id) do {:ok, map} -> plan = Map.get(map, :subscription_plan, @default_subscription_plan) {:ok, plan != @default_subscription_plan} _ -> {:error, :not_cached} end end def add_systems!(map, []), do: map def add_systems!(%{map_id: map_id} = map, [system | rest]) do :ok = add_system(map_id, system) add_systems!(map, rest) end def add_system(map_id, %{solar_system_id: solar_system_id} = system) do systems = map_id |> get_map!() |> Map.get(:systems, Map.new()) case not Map.has_key?(systems, solar_system_id) do true -> map_id |> update_map(%{systems: Map.put(systems, solar_system_id, system)}) :ok _ -> :ok end end def update_system_by_solar_system_id( map_id, update ) do updated_systems = map_id |> get_map!() |> Map.get(:systems, Map.new()) |> update_by_solar_system_id(update) map_id |> update_map(%{systems: updated_systems}) :ok end def remove_system(map_id, solar_system_id) do systems = map_id |> get_map!() |> Map.get(:systems, Map.new()) case systems |> Map.get(solar_system_id) do nil -> :ok _system -> map_id |> update_map(%{systems: Map.drop(systems, [solar_system_id])}) :ok end end def remove_systems(_map_id, []), do: :ok def remove_systems(map_id, [solar_system_id | rest]) do :ok = remove_system(map_id, solar_system_id) remove_systems(map_id, rest) end def add_hub(map_id, %{solar_system_id: solar_system_id} = _hub_info) do hubs = map_id |> get_map!() |> Map.get(:hubs, []) case hubs |> Enum.member?("#{solar_system_id}") do false -> map_id |> update_map(%{hubs: ["#{solar_system_id}" | hubs]}) :ok _ -> :ok end end def remove_hub(map_id, %{solar_system_id: solar_system_id} = _hub_info) do hubs = map_id |> get_map!() |> Map.get(:hubs, []) case hubs |> Enum.member?("#{solar_system_id}") do true -> map_id |> update_map(%{hubs: Enum.reject(hubs, fn hub -> hub == "#{solar_system_id}" end)}) :ok _ -> :ok end end def add_connections!(map, []), do: map def add_connections!(%{map_id: map_id} = map, [connection | rest]) do case add_connection(map_id, connection) do :ok -> add_connections!(map, rest) {:error, :already_exists} -> connection |> WandererApp.MapConnectionRepo.destroy!() add_connections!(map, rest) end end def add_connection( map_id, %{solar_system_source: solar_system_source, solar_system_target: solar_system_target} = connection ) do connections = map_id |> get_map!() |> Map.get(:connections, Map.new()) case not (connections |> is_connection_exist?(connection)) do true -> map_id |> update_map(%{ connections: Map.put_new(connections, "#{solar_system_source}_#{solar_system_target}", connection) }) :ok _ -> :ok end end def is_connection_exist?( connections, %{solar_system_source: solar_system_source, solar_system_target: solar_system_target} = _connection ) do connections |> Map.has_key?("#{solar_system_source}_#{solar_system_target}") or connections |> Map.has_key?("#{solar_system_target}_#{solar_system_source}") end def update_connection( map_id, %{solar_system_source: solar_system_source, solar_system_target: solar_system_target} = connection ) do connections = map_id |> get_map!() |> Map.get(:connections, Map.new()) map_id |> update_map(%{ connections: Map.put(connections, "#{solar_system_source}_#{solar_system_target}", connection) }) :ok end def remove_connection( map_id, %{solar_system_source: solar_system_source, solar_system_target: solar_system_target} = _connection ) do connections = map_id |> get_map!() |> Map.get(:connections, Map.new()) map_id |> update_map(%{ connections: Map.drop(connections, ["#{solar_system_source}_#{solar_system_target}"]) }) :ok end def remove_connections(_map_id, []), do: :ok def remove_connections(map_id, [connection | rest]) do :ok = remove_connection(map_id, connection) remove_connections(map_id, rest) end def find_connections(map_id, solar_system_id), do: map_id |> list_connections!() |> filter( %{solar_system_source: solar_system_id, solar_system_target: solar_system_id}, match: :any ) def find_connection( map_id, solar_system_source, solar_system_target ) do connections = map_id |> get_map!() |> Map.get(:connections, Map.new()) case connections |> Map.get("#{solar_system_source}_#{solar_system_target}") do nil -> {:ok, connections |> Map.get("#{solar_system_target}_#{solar_system_source}")} connection -> {:ok, connection} end end def get_by_id(list, id) do case find(list, %{id: id}, match: :any) do %{} = item -> {:ok, item} nil -> {:error, :item_not_found} end end def find(list, %{} = attrs, match: :any) do list |> Enum.find(fn item -> Enum.any?(attrs, &has_equal_attribute?(item, &1)) end) end def find(list, %{} = attrs, match: :all) do list |> Enum.find(fn item -> Enum.all?(attrs, &has_equal_attribute?(item, &1)) end) end def filter(list, %{} = attrs, match: :any) do list |> Enum.filter(fn item -> Enum.any?(attrs, &has_equal_attribute?(item, &1)) end) end defp has_equal_attribute?(%{} = map, {key, {:case_insensitive, value}}) when is_binary(value) do String.downcase(Map.get(map, key, "")) == String.downcase(value) end defp has_equal_attribute?(%{} = map, {key, value}) do Map.get(map, key) == value end defp update_by_solar_system_id(systems, %{solar_system_id: solar_system_id} = item) do case systems |> Map.get(solar_system_id) do nil -> systems system -> systems |> Map.put(solar_system_id, system |> Map.merge(item)) end end @doc """ Returns the raw activity data that can be processed by WandererApp.Character.Activity. Only includes characters that are on the map's ACL. If days parameter is provided, filters activity to that time period. """ def get_character_activity(map_id, days \\ nil) do with {:ok, map} <- WandererApp.Api.Map.by_id(map_id) do _map_with_acls = Ash.load!(map, :acls) # Calculate cutoff date if days is provided cutoff_date = if days, do: DateTime.utc_now() |> DateTime.add(-days * 24 * 3600, :second), else: nil # Get activity data passages_activity = get_passages_activity(map_id, cutoff_date) connections_activity = get_connections_activity(map_id, cutoff_date) signatures_activity = get_signatures_activity(map_id, cutoff_date) # Return activity data result = passages_activity |> Enum.map(fn passage -> %{ character: passage.character, passages: passage.count, connections: Map.get(connections_activity, passage.character.id, 0), signatures: Map.get(signatures_activity, passage.character.id, 0), timestamp: DateTime.utc_now(), character_id: passage.character.id, user_id: passage.character.user_id } end) {:ok, result} end end defp get_passages_activity(map_id, nil) do # Query all map chain passages without time filter from(p in WandererApp.Api.MapChainPassages, join: c in assoc(p, :character), where: p.map_id == ^map_id, group_by: [c.id], select: {c, count(p.id)} ) |> WandererApp.Repo.all() |> Enum.map(fn {character, count} -> %{character: character, count: count} end) end defp get_passages_activity(map_id, cutoff_date) do # Query map chain passages with time filter from(p in WandererApp.Api.MapChainPassages, join: c in assoc(p, :character), where: p.map_id == ^map_id and p.inserted_at > ^cutoff_date, group_by: [c.id], select: {c, count(p.id)} ) |> WandererApp.Repo.all() |> Enum.map(fn {character, count} -> %{character: character, count: count} end) end defp get_connections_activity(map_id, nil) do # Query all connection activity without time filter from(ua in WandererApp.Api.UserActivity, join: c in assoc(ua, :character), where: ua.entity_id == ^map_id and ua.entity_type == :map and ua.event_type == :map_connection_added, group_by: [c.id], select: {c.id, count(ua.id)} ) |> WandererApp.Repo.all() |> Map.new() end defp get_connections_activity(map_id, cutoff_date) do from(ua in WandererApp.Api.UserActivity, join: c in assoc(ua, :character), where: ua.entity_id == ^map_id and ua.entity_type == :map and ua.event_type == :map_connection_added and ua.inserted_at > ^cutoff_date, group_by: [c.id], select: {c.id, count(ua.id)} ) |> WandererApp.Repo.all() |> Map.new() end defp get_signatures_activity(map_id, nil) do # Query all signature activity without time filter from(ua in WandererApp.Api.UserActivity, join: c in assoc(ua, :character), where: ua.entity_id == ^map_id and ua.entity_type == :map and ua.event_type == :signatures_added, select: {ua.character_id, ua.event_data} ) |> WandererApp.Repo.all() |> process_signatures_data() end defp get_signatures_activity(map_id, cutoff_date) do from(ua in WandererApp.Api.UserActivity, join: c in assoc(ua, :character), where: ua.entity_id == ^map_id and ua.entity_type == :map and ua.event_type == :signatures_added and ua.inserted_at > ^cutoff_date, select: {ua.character_id, ua.event_data} ) |> WandererApp.Repo.all() |> process_signatures_data() end defp process_signatures_data(signatures_data) do signatures_data |> Enum.group_by(fn {character_id, _} -> character_id end) |> Enum.map(&process_character_signatures/1) |> Map.new() end defp process_character_signatures({character_id, activities}) do signature_count = activities |> Enum.map(fn {_, event_data} -> case Jason.decode(event_data) do {:ok, data} -> length(Map.get(data, "signatures", [])) _ -> 0 end end) |> Enum.sum() {character_id, signature_count} end end