Compare commits

..

36 Commits

Author SHA1 Message Date
CI
cccab2a985 chore: release version v1.64.3 2025-05-14 10:14:49 +00:00
Dmitry Popov
1abaa90a7d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-05-14 11:54:41 +02:00
Dmitry Popov
6e1993ca8a fix(Core): Fixed character tracking initialization logic & removed search caching 2025-05-14 11:54:37 +02:00
CI
171c821ac4 chore: release version v1.64.2
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / Manual Approval (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-05-13 21:51:53 +00:00
Dmitry Popov
7ebf9186bf Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-05-13 23:42:32 +02:00
Dmitry Popov
57d2f2baef fix(Core): Fixed tracking of ship & location for offline characters 2025-05-13 23:42:30 +02:00
CI
0aee13878a chore: release version v1.64.1 2025-05-13 19:40:55 +00:00
Dmitry Popov
f93ef0ca76 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-05-13 21:31:06 +02:00
Dmitry Popov
4ec03d8338 fix(Core): Fixed tracking stopped due to server errors 2025-05-13 21:31:02 +02:00
CI
733482cd5c chore: release version v1.64.0
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / Manual Approval (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-05-13 10:42:59 +00:00
Dmitry Popov
3969d1287d fix(Core): Fixed EOL connections cleanup 2025-05-13 12:26:58 +02:00
Dmitry Popov
1aa7854b0d chore: Added ESI API cached responces based on expire headers 2025-05-13 12:08:06 +02:00
Dmitry Popov
7b27d4a1a7 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-05-13 11:09:39 +02:00
Dmitry Popov
24ddb8771f fix(Core): Avoid Zarzakh system in routes widget 2025-05-13 11:09:35 +02:00
Dmitry Popov
7134714245 Merge pull request #376 from wanderer-industries/develop
Develop
2025-05-13 13:02:47 +04:00
guarzo
96b320ac26 fix: remove repeat errors for token refresh (#375) 2025-05-13 13:01:05 +04:00
guarzo
b88e121b30 fix: updated openapi spec for character activity (#374) 2025-05-12 19:09:18 +04:00
guarzo
4ba4119c2b fix: removed error from characters endpoint, and updated routes (#372) 2025-05-12 11:15:20 +04:00
Dmitry Popov
91d1ca201c Merge branch 'main' into develop 2025-05-11 14:35:53 +02:00
guarzo
8bf063a228 fix: cleanup examples for system and connections (#370) 2025-05-11 16:20:23 +04:00
CI
4f53de39b1 chore: release version v1.63.0
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / Manual Approval (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-05-11 12:17:06 +00:00
Dmitry Popov
8c3804f107 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-05-11 14:03:57 +02:00
Dmitry Popov
1be4ec2b90 feat(Core): Updated map active characters page 2025-05-11 14:03:53 +02:00
Dmitry Popov
8f0ed44b11 chore: release version v1.62.3 2025-05-10 20:46:01 +02:00
CI
cbadfc4ac4 chore: release version v1.62.4
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / Manual Approval (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-05-10 12:08:58 +00:00
Dmitry Popov
3d88ae4452 fix(Core): Fixed map characters got untracked 2025-05-10 13:46:49 +02:00
Dmitry Popov
07e2196eb4 Merge branch 'main' into develop 2025-05-09 14:02:39 +02:00
CI
6d99c54af7 chore: release version v1.62.3
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / Manual Approval (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-05-08 12:14:20 +00:00
Dmitry Popov
2b7901e9a8 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-05-08 12:20:14 +02:00
Dmitry Popov
fb06dd1dbc fix(Core): Fixed map characters got untracked 2025-05-08 12:20:09 +02:00
guarzo
d3b825529e fix: remove error on websocket reconnect (#367) 2025-05-07 12:24:17 +04:00
guarzo
ccf9c0db22 feat (api): add additional structure/signature methods (#365) 2025-05-06 20:42:47 +04:00
CI
f8ba36b8be chore: release version v1.62.2
Some checks failed
Build / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build / Manual Approval (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-05-05 18:51:41 +00:00
Dmitry Popov
5bf9d99b3d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-05-05 20:32:17 +02:00
Dmitry Popov
7cad05342a fix(Core): Fixed audit export API 2025-05-05 20:32:14 +02:00
guarzo
6378754c57 feat (api): add additional system/connections methods (#351) 2025-05-03 19:41:21 +04:00
65 changed files with 8823 additions and 1854 deletions

View File

@@ -2,6 +2,96 @@
<!-- changelog -->
## [v1.64.3](https://github.com/wanderer-industries/wanderer/compare/v1.64.2...v1.64.3) (2025-05-14)
### Bug Fixes:
* Core: Fixed character tracking initialization logic & removed search caching
## [v1.64.2](https://github.com/wanderer-industries/wanderer/compare/v1.64.1...v1.64.2) (2025-05-13)
### Bug Fixes:
* Core: Fixed tracking of ship & location for offline characters
## [v1.64.1](https://github.com/wanderer-industries/wanderer/compare/v1.64.0...v1.64.1) (2025-05-13)
### Bug Fixes:
* Core: Fixed tracking stopped due to server errors
## [v1.64.0](https://github.com/wanderer-industries/wanderer/compare/v1.63.0...v1.64.0) (2025-05-13)
### Features:
* api: add additional structure/signature methods (#365)
* api: add additional system/connections methods (#351)
### Bug Fixes:
* Core: Fixed EOL connections cleanup
* Core: Avoid Zarzakh system in routes widget
* remove repeat errors for token refresh (#375)
* updated openapi spec for character activity (#374)
* removed error from characters endpoint, and updated routes (#372)
* cleanup examples for system and connections (#370)
* remove error on websocket reconnect (#367)
## [v1.63.0](https://github.com/wanderer-industries/wanderer/compare/v1.62.4...v1.63.0) (2025-05-11)
### Features:
* Core: Updated map active characters page
## [v1.62.4](https://github.com/wanderer-industries/wanderer/compare/v1.62.3...v1.62.4) (2025-05-10)
### Bug Fixes:
* Core: Fixed map characters got untracked
## [v1.62.3](https://github.com/wanderer-industries/wanderer/compare/v1.62.2...v1.62.3) (2025-05-08)
### Bug Fixes:
* Core: Fixed map characters got untracked
## [v1.62.2](https://github.com/wanderer-industries/wanderer/compare/v1.62.1...v1.62.2) (2025-05-05)
### Bug Fixes:
* Core: Fixed audit export API
## [v1.62.1](https://github.com/wanderer-industries/wanderer/compare/v1.62.0...v1.62.1) (2025-05-05)

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -5,6 +5,16 @@ defmodule WandererApp.Api.MapCharacterSettings do
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer
@derive {Jason.Encoder, only: [
:id,
:map_id,
:character_id,
:tracked,
:followed,
:inserted_at,
:updated_at
]}
postgres do
repo(WandererApp.Repo)
table("map_character_settings_v1")

View File

@@ -164,4 +164,23 @@ defmodule WandererApp.Api.MapSystemSignature do
identities do
identity :uniq_system_eve_id, [:system_id, :eve_id]
end
@derive {Jason.Encoder,
only: [
:id,
:system_id,
:eve_id,
:character_eve_id,
:name,
:description,
:type,
:linked_system_id,
:kind,
:group,
:custom_info,
:updated,
:inserted_at,
:updated_at
]
}
end

View File

@@ -4,6 +4,27 @@ defmodule WandererApp.Api.MapSystemStructure do
"""
@derive {Jason.Encoder,
only: [
:id,
:system_id,
:solar_system_id,
:solar_system_name,
:structure_type_id,
:structure_type,
:character_eve_id,
:name,
:notes,
:owner_name,
:owner_ticker,
:owner_id,
:status,
:end_time,
:inserted_at,
:updated_at
]
}
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer

View File

@@ -13,35 +13,40 @@ defmodule WandererApp.Application do
WandererAppWeb.Telemetry,
WandererApp.Vault,
WandererApp.Repo,
{Phoenix.PubSub, name: WandererApp.PubSub, adapter_name: Phoenix.PubSub.PG2},
{
Finch,
name: WandererApp.Finch,
pools: %{
default: [
size: 25, # number of connections per pool
count: 2, # number of pools (so total 50 connections)
# number of connections per pool
size: 25,
# number of pools (so total 50 connections)
count: 2
]
}
},
WandererApp.Cache,
Supervisor.child_spec({Cachex, name: :system_static_info_cache}, id: :system_static_info_cache_worker),
Supervisor.child_spec({Cachex, name: :ship_types_cache}, id: :ship_types_cache_worker),
Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker),
Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker),
Supervisor.child_spec({Cachex, name: :character_state_cache}, id: :character_state_cache_worker),
Supervisor.child_spec({Cachex, name: :api_cache}, id: :api_cache_worker),
Supervisor.child_spec({Cachex, name: :system_static_info_cache},
id: :system_static_info_cache_worker
),
Supervisor.child_spec({Cachex, name: :ship_types_cache}, id: :ship_types_cache_worker),
Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker),
Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker),
Supervisor.child_spec({Cachex, name: :character_state_cache},
id: :character_state_cache_worker
),
Supervisor.child_spec({Cachex, name: :tracked_characters},
id: :tracked_characters_cache_worker
),
WandererApp.Scheduler,
{Registry, keys: :unique, name: WandererApp.MapRegistry},
{Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry},
{PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
{PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
{PartitionSupervisor,
child_spec: DynamicSupervisor, name: WandererApp.Map.DynamicSupervisors},
{PartitionSupervisor,
child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors},
WandererApp.Zkb.Supervisor,
WandererApp.Server.ServerStatusTracker,
WandererApp.Server.TheraDataFetcher,
@@ -49,11 +54,10 @@ defmodule WandererApp.Application do
WandererApp.Character.TrackerManager,
WandererApp.Map.Manager,
WandererApp.Map.ZkbDataFetcher,
WandererAppWeb.Presence,
WandererAppWeb.Endpoint
]
++ maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?())
] ++
maybe_start_corp_wallet_tracker(WandererApp.Env.map_subscriptions_enabled?())
opts = [strategy: :one_for_one, name: WandererApp.Supervisor]

View File

@@ -33,7 +33,7 @@ defmodule WandererApp.Character.Tracker do
status: binary()
}
@online_error_timeout :timer.minutes(2)
@online_error_timeout :timer.minutes(3)
@forbidden_ttl :timer.minutes(1)
@pubsub_client Application.compile_env(:wanderer_app, :pubsub_client)
@@ -49,7 +49,7 @@ defmodule WandererApp.Character.Tracker do
|> new()
end
def update_track_settings(character_id, track_settings) do
def update_settings(character_id, track_settings) do
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
{:ok,
@@ -103,7 +103,9 @@ defmodule WandererApp.Character.Tracker do
|> update_ship()
end
def update_ship(%{character_id: character_id, track_ship: true} = character_state) do
def update_ship(
%{character_id: character_id, track_ship: true, is_online: true} = character_state
) do
character_id
|> WandererApp.Character.get_character()
|> case do
@@ -154,7 +156,9 @@ defmodule WandererApp.Character.Tracker do
|> update_location()
end
def update_location(%{track_location: true, character_id: character_id} = character_state) do
def update_location(
%{track_location: true, is_online: true, character_id: character_id} = character_state
) do
case WandererApp.Character.get_character(character_id) do
{:ok, %{eve_id: eve_id, access_token: access_token}} when not is_nil(access_token) ->
WandererApp.Cache.has_key?("character:#{character_id}:location_forbidden")
@@ -305,14 +309,9 @@ defmodule WandererApp.Character.Tracker do
WandererApp.Cache.delete("character:#{character_id}:online_forbidden")
WandererApp.Cache.delete("character:#{character_id}:online_error_time")
WandererApp.Character.update_character(character_id, %{online: false})
WandererApp.Cache.delete("character:#{character_id}:location_started")
WandererApp.Cache.delete("character:#{character_id}:start_solar_system_id")
WandererApp.Character.update_character_state(character_id, %{
character_state
| is_online: false,
track_ship: false,
track_location: false
is_online: false
})
:ok
@@ -494,7 +493,7 @@ defmodule WandererApp.Character.Tracker do
state,
location
) do
location = get_location(location)
location = get_location(location)
if not is_location_started?(character_id) do
WandererApp.Cache.lookup!("character:#{character_id}:start_solar_system_id", nil)
@@ -544,14 +543,18 @@ defmodule WandererApp.Character.Tracker do
)
defp is_location_updated?(
%{solar_system_id: new_solar_system_id, station_id: new_station_id, structure_id: new_structure_id} = _location,
%{
solar_system_id: new_solar_system_id,
station_id: new_station_id,
structure_id: new_structure_id
} = _location,
solar_system_id,
structure_id,
station_id
),
do:
solar_system_id != new_solar_system_id ||
solar_system_id != new_solar_system_id ||
solar_system_id != new_solar_system_id ||
structure_id != new_structure_id ||
station_id != new_station_id
@@ -724,14 +727,7 @@ defmodule WandererApp.Character.Tracker do
)
end
WandererApp.Character.update_character(character_id, %{online: false})
%{
state
| track_ship: false,
track_online: false,
track_location: false
}
state
end
defp maybe_stop_tracking(

View File

@@ -31,9 +31,7 @@ defmodule WandererApp.Character.TrackerManager do
def init(args) do
Logger.info("#{__MODULE__} started")
{:ok, tracked_characters} = WandererApp.Cache.lookup("tracked_characters", [])
{:ok, Impl.init(args |> Keyword.merge(characters: tracked_characters)), {:continue, :start}}
{:ok, Impl.init(args), {:continue, :start}}
end
@impl true

View File

@@ -32,71 +32,72 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|> new()
end
def start(%{opts: opts} = state) do
opts[:characters]
|> Enum.reduce(state, fn character_id, acc ->
start_tracking(acc, character_id, %{})
def start(state) do
{:ok, tracked_characters} = WandererApp.Cache.lookup("tracked_characters", [])
WandererApp.Cache.insert("tracked_characters", [])
tracked_characters
|> Enum.each(fn character_id ->
start_tracking(state, character_id, %{})
end)
state
end
def start_tracking(%__MODULE__{characters: characters} = state, character_id, opts) do
case Enum.member?(characters, character_id) do
true ->
state
def start_tracking(state, character_id, opts) do
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
false <- Enum.member?(characters, character_id) do
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
false ->
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
tracked_characters = [character_id | characters] |> Enum.uniq()
WandererApp.Cache.insert("tracked_characters", tracked_characters)
WandererApp.TaskWrapper.start_link(WandererApp.Character, :update_character_state, [
character_id,
%{opts: opts}
])
WandererApp.Character.update_character(character_id, %{online: false})
tracked_characters = [character_id | state.characters] |> Enum.uniq()
WandererApp.Cache.insert("tracked_characters", tracked_characters)
WandererApp.Character.update_character_state(character_id, %{
is_online: false
})
WandererApp.Character.TrackerPoolDynamicSupervisor.start_tracking(character_id)
WandererApp.Character.TrackerPoolDynamicSupervisor.start_tracking(character_id)
%{state | characters: tracked_characters}
WandererApp.TaskWrapper.start_link(WandererApp.Character, :update_character_state, [
character_id,
%{opts: opts}
])
end
state
end
def stop_tracking(%__MODULE__{characters: characters} = state, character_id) do
case Enum.member?(characters, character_id) do
true ->
{:ok, character_state} = WandererApp.Character.get_character_state(character_id, false)
def stop_tracking(state, character_id) do
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
true <- Enum.member?(characters, character_id),
{:ok, %{start_time: start_time}} <-
WandererApp.Character.get_character_state(character_id, false) do
Logger.debug(fn -> "Shutting down character tracker: #{inspect(character_id)}" end)
case character_state do
nil ->
state
WandererApp.Cache.delete("character:#{character_id}:last_active_time")
WandererApp.Cache.delete("character:#{character_id}:location_started")
WandererApp.Cache.delete("character:#{character_id}:start_solar_system_id")
WandererApp.Character.delete_character_state(character_id)
%{start_time: start_time} ->
duration = DateTime.diff(DateTime.utc_now(), start_time, :second)
tracked_characters =
characters |> Enum.reject(fn c_id -> c_id == character_id end)
:telemetry.execute([:wanderer_app, :character, :tracker, :running], %{
duration: duration
})
WandererApp.Cache.insert("tracked_characters", tracked_characters)
:telemetry.execute([:wanderer_app, :character, :tracker, :stopped], %{count: 1})
Logger.debug(fn -> "Shutting down character tracker: #{inspect(character_id)}" end)
WandererApp.Character.TrackerPoolDynamicSupervisor.stop_tracking(character_id)
WandererApp.Cache.delete("character:#{character_id}:location_started")
WandererApp.Cache.delete("character:#{character_id}:start_solar_system_id")
WandererApp.Character.delete_character_state(character_id)
duration = DateTime.diff(DateTime.utc_now(), start_time, :second)
tracked_characters =
state.characters |> Enum.reject(fn c_id -> c_id == character_id end)
:telemetry.execute([:wanderer_app, :character, :tracker, :running], %{
duration: duration
})
WandererApp.Cache.insert("tracked_characters", tracked_characters)
WandererApp.Character.TrackerPoolDynamicSupervisor.stop_tracking(character_id)
%{state | characters: tracked_characters}
end
false ->
state
:telemetry.execute([:wanderer_app, :character, :tracker, :stopped], %{count: 1})
end
state
end
def update_track_settings(
@@ -118,7 +119,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
)
{:ok, character_state} =
WandererApp.Character.Tracker.update_track_settings(character_id, track_settings)
WandererApp.Character.Tracker.update_settings(character_id, track_settings)
WandererApp.Character.update_character_state(character_id, character_state)
else
@@ -135,12 +136,12 @@ defmodule WandererApp.Character.TrackerManager.Impl do
end
def get_characters(
%{
characters: characters
} = state,
state,
_opts \\ []
),
do: {characters, state}
) do
{:ok, characters} = WandererApp.Cache.lookup("tracked_characters", [])
{characters, state}
end
def handle_event({ref, result}, state) do
Process.demonitor(ref, [:flush])
@@ -163,13 +164,12 @@ defmodule WandererApp.Character.TrackerManager.Impl do
def handle_info(
:garbage_collect,
%{
characters: characters
} =
state
state
) do
Process.send_after(self(), :garbage_collect, @garbage_collection_interval)
{:ok, characters} = WandererApp.Cache.lookup("tracked_characters", [])
characters
|> Task.async_stream(
fn character_id ->
@@ -213,15 +213,15 @@ defmodule WandererApp.Character.TrackerManager.Impl do
WandererApp.Cache.get_and_remove!("character_untrack_queue", [])
|> Task.async_stream(
fn {map_id, character_id} ->
WandererApp.Cache.delete("map_#{map_id}:character_#{character_id}:tracked")
if not character_is_present(map_id, character_id) do
{:ok, character_state} =
WandererApp.Character.Tracker.update_settings(character_id, %{
map_id: map_id,
track: false
})
{:ok, character_state} =
WandererApp.Character.Tracker.update_track_settings(character_id, %{
map_id: map_id,
track: false
})
WandererApp.Character.update_character_state(character_id, character_state)
WandererApp.Character.update_character_state(character_id, character_state)
end
end,
max_concurrency: System.schedulers_online(),
on_timeout: :kill_task,
@@ -233,21 +233,23 @@ defmodule WandererApp.Character.TrackerManager.Impl do
end
def handle_info({:stop_track, character_id}, state) do
WandererApp.Cache.has_key?("character:#{character_id}:is_stop_tracking")
|> case do
false ->
WandererApp.Cache.insert("character:#{character_id}:is_stop_tracking", true)
Logger.debug(fn -> "Stopping character tracker: #{inspect(character_id)}" end)
state = state |> stop_tracking(character_id)
WandererApp.Cache.delete("character:#{character_id}:is_stop_tracking")
state
_ ->
state
if not WandererApp.Cache.has_key?("character:#{character_id}:is_stop_tracking") do
WandererApp.Cache.insert("character:#{character_id}:is_stop_tracking", true)
Logger.debug(fn -> "Stopping character tracker: #{inspect(character_id)}" end)
stop_tracking(state, character_id)
WandererApp.Cache.delete("character:#{character_id}:is_stop_tracking")
end
state
end
def handle_info(_event, state),
do: state
defp character_is_present(map_id, character_id) do
{:ok, presence_character_ids} =
WandererApp.Cache.lookup("map_#{map_id}:presence_character_ids", [])
Enum.member?(presence_character_ids, character_id)
end
end

View File

@@ -17,11 +17,11 @@ defmodule WandererApp.Character.TrackerPool do
@unique_registry :unique_tracker_pool_registry
@update_location_interval :timer.seconds(2)
@update_online_interval :timer.seconds(10)
@update_online_interval :timer.seconds(5)
@check_online_errors_interval :timer.seconds(30)
@update_ship_interval :timer.seconds(5)
@update_ship_interval :timer.seconds(2)
@update_info_interval :timer.minutes(1)
@update_wallet_interval :timer.minutes(5)
@update_wallet_interval :timer.minutes(1)
@inactive_character_timeout :timer.minutes(5)
@logger Application.compile_env(:wanderer_app, :logger)
@@ -167,10 +167,22 @@ defmodule WandererApp.Character.TrackerPool do
def handle_info(
:update_online,
state
%{
characters: characters
} =
state
) do
Process.send_after(self(), :update_online, @update_online_interval)
characters
|> Enum.each(fn character_id ->
WandererApp.Character.update_character(character_id, %{online: false})
WandererApp.Character.update_character_state(character_id, %{
is_online: false
})
end)
{:noreply, state}
end

View File

@@ -120,7 +120,7 @@ defmodule WandererApp.Character.TrackingUtils do
{:ok, updated_settings} =
WandererApp.MapCharacterSettingsRepo.untrack(existing_settings)
:ok = untrack_characters([character], map_id, caller_pid)
:ok = untrack([character], map_id, caller_pid)
:ok = remove_characters([character], map_id)
{:ok, updated_settings}
else
@@ -131,7 +131,7 @@ defmodule WandererApp.Character.TrackingUtils do
{:ok, %{tracked: false} = existing_settings} ->
if track do
{:ok, updated_settings} = WandererApp.MapCharacterSettingsRepo.track(existing_settings)
:ok = track_characters([character], map_id, true, caller_pid)
:ok = track([character], map_id, true, caller_pid)
:ok = add_characters([character], map_id, true)
{:ok, updated_settings}
else
@@ -148,7 +148,7 @@ defmodule WandererApp.Character.TrackingUtils do
tracked: true
})
:ok = track_characters([character], map_id, true, caller_pid)
:ok = track([character], map_id, true, caller_pid)
:ok = add_characters([character], map_id, true)
{:ok, settings}
else
@@ -161,61 +161,86 @@ defmodule WandererApp.Character.TrackingUtils do
end
# Helper functions for character tracking
def track_characters(_, _, false, _), do: :ok
def track_characters([], _map_id, _is_track_character?, _), do: :ok
def track_characters([character | characters], map_id, true, caller_pid) do
with :ok <- track_character(character, map_id, caller_pid) do
track_characters(characters, map_id, true, caller_pid)
def track([], _map_id, _is_track_character?, _), do: :ok
def track([character | characters], map_id, is_track_allowed, caller_pid) do
with :ok <- track_character(character, map_id, is_track_allowed, caller_pid) do
track(characters, map_id, is_track_allowed, caller_pid)
end
end
def track_character(
%{
id: character_id,
eve_id: eve_id,
corporation_id: corporation_id,
alliance_id: alliance_id
},
map_id,
caller_pid
) do
with false <- is_nil(caller_pid) do
WandererAppWeb.Presence.track(caller_pid, map_id, character_id, %{})
defp track_character(
%{
id: character_id,
eve_id: eve_id
},
map_id,
is_track_allowed,
caller_pid
)
when not is_nil(caller_pid) do
WandererAppWeb.Presence.update(caller_pid, map_id, character_id, %{
tracked: is_track_allowed,
from: DateTime.utc_now()
})
|> case do
{:ok, _} ->
:ok
cache_key = "#{inspect(caller_pid)}_map_#{map_id}:character_#{character_id}:tracked"
{:error, :nopresence} ->
WandererAppWeb.Presence.track(caller_pid, map_id, character_id, %{
tracked: is_track_allowed,
from: DateTime.utc_now()
})
case WandererApp.Cache.lookup!(cache_key, false) do
true ->
:ok
error ->
Logger.error("Failed to update presence: #{inspect(error)}")
{:error, "Failed to update presence"}
end
_ ->
:ok = Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{eve_id}")
:ok = WandererApp.Cache.put(cache_key, true)
end
cache_key = "#{inspect(caller_pid)}_map_#{map_id}:character_#{character_id}:tracked"
:ok = WandererApp.Character.TrackerManager.start_tracking(character_id)
else
case WandererApp.Cache.lookup!(cache_key, false) do
true ->
Logger.error("caller_pid is required for tracking characters")
{:error, "caller_pid is required"}
:ok
_ ->
:ok = Phoenix.PubSub.subscribe(WandererApp.PubSub, "character:#{eve_id}")
:ok = WandererApp.Cache.put(cache_key, true)
end
if is_track_allowed do
:ok = WandererApp.Character.TrackerManager.start_tracking(character_id)
end
:ok
end
def untrack_characters(characters, map_id, caller_pid) do
defp track_character(
_character,
_map_id,
_is_track_allowed,
_caller_pid
) do
Logger.error("caller_pid is required for tracking characters")
{:error, "caller_pid is required"}
end
def untrack(characters, map_id, caller_pid) do
with false <- is_nil(caller_pid) do
character_ids = characters |> Enum.map(& &1.id)
characters
|> Enum.each(fn character ->
WandererAppWeb.Presence.untrack(caller_pid, map_id, character.id)
WandererApp.Cache.put(
"#{inspect(caller_pid)}_map_#{map_id}:character_#{character.id}:tracked",
false
)
:ok = Phoenix.PubSub.unsubscribe(WandererApp.PubSub, "character:#{character.eve_id}")
WandererAppWeb.Presence.update(caller_pid, map_id, character.id, %{
tracked: false,
from: DateTime.utc_now()
})
end)
WandererApp.Map.Server.untrack_characters(map_id, character_ids)
:ok
else
true ->

View File

@@ -31,8 +31,11 @@ defmodule WandererApp.Esi.ApiClient do
avoid: []
}
@zarzakh_system 30_100_000
@default_avoid_systems [@zarzakh_system]
@cache_opts [cache: true]
@retry_opts [max_retries: 1, retry_log_level: :warning]
@retry_opts [max_retries: 0, retry_log_level: :warning]
@timeout_opts [pool_timeout: 15_000, receive_timeout: :timer.seconds(30)]
@api_retry_count 1
@@ -99,7 +102,7 @@ defmodule WandererApp.Esi.ApiClient do
{:ok, []}
end
chains = _remove_intersection([map_chains | thera_chains] |> List.flatten())
chains = remove_intersection([map_chains | thera_chains] |> List.flatten())
chains =
case routes_settings.include_cruise do
@@ -170,7 +173,10 @@ defmodule WandererApp.Esi.ApiClient do
avoidance_list
end
avoidance_list = [routes_settings.avoid | avoidance_list] |> List.flatten() |> Enum.uniq()
avoidance_list =
(@default_avoid_systems ++ [routes_settings.avoid | avoidance_list])
|> List.flatten()
|> Enum.uniq()
params =
%{
@@ -296,7 +302,7 @@ defmodule WandererApp.Esi.ApiClient do
opts: [ttl: @ttl]
)
def get_killmail(killmail_id, killmail_hash, opts \\ []) do
get("/killmails/#{killmail_id}/#{killmail_hash}/", opts)
get("/killmails/#{killmail_id}/#{killmail_hash}/", opts, @cache_opts)
end
@decorate cacheable(
@@ -319,7 +325,8 @@ defmodule WandererApp.Esi.ApiClient do
def get_character_info(eve_id, opts \\ []) do
case get(
"/characters/#{eve_id}/",
opts
opts,
@cache_opts
) do
{:ok, result} -> {:ok, result |> Map.put("eve_id", eve_id)}
{:error, error} -> {:error, error}
@@ -333,25 +340,35 @@ defmodule WandererApp.Esi.ApiClient do
def get_custom_route_base_url, do: WandererApp.Env.custom_route_base_url()
def get_character_wallet(character_eve_id, opts \\ []),
do: _get_character_auth_data(character_eve_id, "wallet", opts)
do: get_character_auth_data(character_eve_id, "wallet", opts ++ @cache_opts)
def get_corporation_wallets(corporation_id, opts \\ []),
do: _get_corporation_auth_data(corporation_id, "wallets", opts)
do: get_corporation_auth_data(corporation_id, "wallets", opts)
def get_corporation_wallet_journal(corporation_id, division, opts \\ []),
do: _get_corporation_auth_data(corporation_id, "wallets/#{division}/journal", opts)
do:
get_corporation_auth_data(
corporation_id,
"wallets/#{division}/journal",
opts
)
def get_corporation_wallet_transactions(corporation_id, division, opts \\ []),
do: _get_corporation_auth_data(corporation_id, "wallets/#{division}/transactions", opts)
do:
get_corporation_auth_data(
corporation_id,
"wallets/#{division}/transactions",
opts
)
def get_character_location(character_eve_id, opts \\ []),
do: _get_character_auth_data(character_eve_id, "location", opts)
do: get_character_auth_data(character_eve_id, "location", opts ++ @cache_opts)
def get_character_online(character_eve_id, opts \\ []),
do: _get_character_auth_data(character_eve_id, "online", opts)
do: get_character_auth_data(character_eve_id, "online", opts ++ @cache_opts)
def get_character_ship(character_eve_id, opts \\ []),
do: _get_character_auth_data(character_eve_id, "ship", opts)
do: get_character_auth_data(character_eve_id, "ship", opts ++ @cache_opts)
def search(character_eve_id, opts \\ []) do
search_val = to_string(opts[:params][:search] || "")
@@ -366,7 +383,7 @@ defmodule WandererApp.Esi.ApiClient do
]
merged_opts = Keyword.put(opts, :params, query_params)
_search(character_eve_id, search_val, categories_val, merged_opts)
get_search(character_eve_id, search_val, categories_val, merged_opts)
end
@decorate cacheable(
@@ -374,11 +391,11 @@ defmodule WandererApp.Esi.ApiClient do
key: "search-#{character_eve_id}-#{categories_val}-#{search_val |> Slug.slugify()}",
opts: [ttl: @ttl]
)
defp _search(character_eve_id, search_val, categories_val, merged_opts) do
_get_character_auth_data(character_eve_id, "search", merged_opts)
defp get_search(character_eve_id, search_val, categories_val, merged_opts) do
get_character_auth_data(character_eve_id, "search", merged_opts)
end
defp _remove_intersection(pairs_arr) do
defp remove_intersection(pairs_arr) do
tuples = pairs_arr |> Enum.map(fn x -> {x.first, x.second} end)
tuples
@@ -399,9 +416,9 @@ defmodule WandererApp.Esi.ApiClient do
end
defp _get_routes(origin, destination, params, opts),
do: _get_routes_eve(origin, destination, params, opts)
do: get_routes_eve(origin, destination, params, opts)
defp _get_routes_eve(origin, destination, params, opts) do
defp get_routes_eve(origin, destination, params, opts) do
esi_params =
Map.merge(params, %{
connections: params.connections |> Enum.join(","),
@@ -410,7 +427,8 @@ defmodule WandererApp.Esi.ApiClient do
get(
"/route/#{origin}/#{destination}/?#{esi_params |> Plug.Conn.Query.encode()}",
opts
opts,
@cache_opts
)
end
@@ -420,17 +438,19 @@ defmodule WandererApp.Esi.ApiClient do
do:
get(
"/alliances/#{alliance_eve_id}/#{info_path}",
opts
opts,
@cache_opts
)
defp _get_corporation_info(corporation_eve_id, info_path, opts),
do:
get(
"/corporations/#{corporation_eve_id}/#{info_path}",
opts
opts,
@cache_opts
)
defp _get_character_auth_data(character_eve_id, info_path, opts) do
defp get_character_auth_data(character_eve_id, info_path, opts) do
path = "/characters/#{character_eve_id}/#{info_path}"
auth_opts =
@@ -439,7 +459,7 @@ defmodule WandererApp.Esi.ApiClient do
character_id = opts |> Keyword.get(:character_id, nil)
if not _is_access_token_expired?(character_id) do
if not is_access_token_expired?(character_id) do
get(
path,
auth_opts,
@@ -450,7 +470,7 @@ defmodule WandererApp.Esi.ApiClient do
end
end
defp _is_access_token_expired?(character_id) do
defp is_access_token_expired?(character_id) do
{:ok, %{expires_at: expires_at} = _character} =
WandererApp.Character.get_character(character_id)
@@ -459,13 +479,13 @@ defmodule WandererApp.Esi.ApiClient do
expires_at - now <= 0
end
defp _get_corporation_auth_data(corporation_eve_id, info_path, opts),
defp get_corporation_auth_data(corporation_eve_id, info_path, opts),
do:
get(
"/corporations/#{corporation_eve_id}/#{info_path}",
[params: opts[:params] || []] ++
(opts |> get_auth_opts()),
opts
opts ++ @cache_opts
)
defp with_user_agent_opts(opts) do
@@ -487,12 +507,28 @@ defmodule WandererApp.Esi.ApiClient do
)
defp get(path, api_opts \\ [], opts \\ []) do
case Cachex.get(:api_cache, path) do
{:ok, cached_data} when not is_nil(cached_data) ->
{:ok, cached_data}
_ ->
do_get_request(path, api_opts, opts)
end
end
defp do_get_request(path, api_opts \\ [], opts \\ []) do
try do
case Req.get(
"#{@base_url}#{path}",
api_opts |> with_user_agent_opts() |> with_cache_opts() |> Keyword.merge(@retry_opts) |> Keyword.merge(@timeout_opts)
api_opts
|> with_user_agent_opts()
|> with_cache_opts()
|> Keyword.merge(@retry_opts)
|> Keyword.merge(@timeout_opts)
) do
{:ok, %{status: 200, body: body}} ->
{:ok, %{status: 200, body: body, headers: headers}} ->
maybe_cache_response(path, body, headers, opts)
{:ok, body}
{:ok, %{status: 504}} ->
@@ -521,6 +557,30 @@ defmodule WandererApp.Esi.ApiClient do
end
end
defp maybe_cache_response(path, body, %{"expires" => [expires]}, opts)
when is_binary(path) and not is_nil(expires) do
try do
if opts |> Keyword.get(:cache, false) do
cached_ttl =
DateTime.diff(Timex.parse!(expires, "{RFC1123}"), DateTime.utc_now(), :millisecond)
Cachex.put(
:api_cache,
path,
body,
ttl: cached_ttl
)
end
rescue
e ->
@logger.error(Exception.message(e))
:ok
end
end
defp maybe_cache_response(_path, _body, _headers), do: :ok
defp post(url, opts) do
try do
case Req.post("#{url}", opts |> with_user_agent_opts()) do
@@ -578,63 +638,91 @@ defmodule WandererApp.Esi.ApiClient do
{:ok, %{expires_at: expires_at, refresh_token: refresh_token, scopes: scopes} = character} =
WandererApp.Character.get_character(character_id)
case WandererApp.Ueberauth.Strategy.Eve.OAuth.get_refresh_token([],
with_wallet: WandererApp.Character.can_track_wallet?(character),
is_admin?: WandererApp.Character.can_track_corp_wallet?(character),
token: %OAuth2.AccessToken{refresh_token: refresh_token}
) do
{:ok, %OAuth2.AccessToken{} = token} ->
{:ok, _character} =
character
|> WandererApp.Api.Character.update(%{
access_token: token.access_token,
expires_at: token.expires_at,
scopes: scopes
})
refresh_token_result =
WandererApp.Ueberauth.Strategy.Eve.OAuth.get_refresh_token([],
with_wallet: WandererApp.Character.can_track_wallet?(character),
is_admin?: WandererApp.Character.can_track_corp_wallet?(character),
token: %OAuth2.AccessToken{refresh_token: refresh_token}
)
WandererApp.Character.update_character(character_id, %{
access_token: token.access_token,
expires_at: token.expires_at
})
handle_refresh_token_result(refresh_token_result, character, character_id, expires_at, scopes)
end
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character_id}",
:token_updated
)
defp handle_refresh_token_result(
{:ok, %OAuth2.AccessToken{} = token},
character,
character_id,
_expires_at,
scopes
) do
{:ok, _character} =
character
|> WandererApp.Api.Character.update(%{
access_token: token.access_token,
expires_at: token.expires_at,
scopes: scopes
})
{:ok, token}
WandererApp.Character.update_character(character_id, %{
access_token: token.access_token,
expires_at: token.expires_at
})
{:error, {"invalid_grant", error_message}} ->
{:ok, _character} =
character
|> WandererApp.Api.Character.update(%{
access_token: nil,
refresh_token: nil,
expires_at: expires_at,
scopes: scopes
})
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character_id}",
:token_updated
)
WandererApp.Character.update_character(character_id, %{
access_token: nil,
refresh_token: nil,
expires_at: expires_at,
scopes: scopes
})
{:ok, token}
end
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character_id}",
:character_token_invalid
)
defp handle_refresh_token_result(
{:error, {"invalid_grant", error_message}},
character,
character_id,
expires_at,
scopes
) do
invalidate_character_tokens(character, character_id, expires_at, scopes)
Logger.warning("Failed to refresh token for #{character_id}: #{error_message}")
{:error, :invalid_grant}
end
Logger.warning("Failed to refresh token for #{character_id}: #{error_message}")
{:error, :invalid_grant}
defp handle_refresh_token_result(
{:error, %OAuth2.Error{} = error},
character,
character_id,
expires_at,
scopes
) do
invalidate_character_tokens(character, character_id, expires_at, scopes)
Logger.warning("Failed to refresh token for #{character_id}: #{inspect(error)}")
{:error, :invalid_grant}
end
defp handle_refresh_token_result(error, character, character_id, expires_at, scopes) do
Logger.warning("Failed to refresh token for #{character_id}: #{inspect(error)}")
invalidate_character_tokens(character, character_id, expires_at, scopes)
{:error, :failed}
end
defp invalidate_character_tokens(character, character_id, expires_at, scopes) do
attrs = %{access_token: nil, refresh_token: nil, expires_at: expires_at, scopes: scopes}
with {:ok, _} <- WandererApp.Api.Character.update(character, attrs),
{:ok, _} <- WandererApp.Character.update_character(character_id, attrs) do
:ok
else
error ->
Logger.warning("Failed to refresh token for #{character_id}: #{inspect(error)}")
{:error, :failed}
Logger.error("Failed to clear tokens for #{character_id}: #{inspect(error)}")
end
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{character_id}",
:character_token_invalid
)
end
defp map_route_info(

View File

@@ -0,0 +1,128 @@
# File: lib/wanderer_app/map/operations.ex
defmodule WandererApp.Map.Operations do
@moduledoc """
Central entrypoint for map operations. Delegates responsibilities to specialized submodules:
- Owner: Fetching and caching owner character info
- Systems: CRUD and batch upsert for systems
- Connections: CRUD and batch upsert for connections
- Structures: CRUD for structures
- Signatures: CRUD for signatures
"""
alias WandererApp.Map.Operations.{
Owner,
Systems,
Connections,
Structures,
Signatures
}
# -- Owner Info -------------------------------------------------------------
@doc "Fetch cached main character info for a map owner"
@spec get_owner_character_id(String.t()) ::
{:ok, %{id: term(), user_id: term()}} | {:error, String.t()}
defdelegate get_owner_character_id(map_id), to: Owner
# -- Systems ----------------------------------------------------------------
@doc "List visible systems"
@spec list_systems(String.t()) :: [map()]
defdelegate list_systems(map_id), to: Systems
@doc "Get a specific system"
@spec get_system(String.t(), integer()) :: {:ok, map()} | {:error, :not_found}
defdelegate get_system(map_id, system_id), to: Systems
@doc "Create a system"
@spec create_system(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
defdelegate create_system(map_id, params), to: Systems
@doc "Update a system"
@spec update_system(String.t(), integer(), map()) ::
{:ok, map()} | {:error, String.t()}
defdelegate update_system(map_id, system_id, attrs), to: Systems
@doc "Delete a system"
@spec delete_system(String.t(), integer()) :: {:ok, integer()} | {:error, term()}
defdelegate delete_system(map_id, system_id), to: Systems
@doc "Upsert systems and connections in batch"
@spec upsert_systems_and_connections(String.t(), [map()], [map()]) ::
{:ok, map()} | {:error, String.t()}
defdelegate upsert_systems_and_connections(map_id, systems, connections), to: Systems
# -- Connections -----------------------------------------------------------
@doc "List all connections"
@spec list_connections(String.t()) :: [map()]
defdelegate list_connections(map_id), to: Connections
@doc "List connections for a specific system"
@spec list_connections(String.t(), integer()) :: [map()]
defdelegate list_connections(map_id, system_id), to: Connections
@doc "Get a connection"
@spec get_connection(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()}
defdelegate get_connection(map_id, connection_id), to: Connections
@doc "Create a connection"
@spec create_connection(String.t(), map()) ::
{:ok, map()} | {:skip, :exists} | {:error, String.t()}
defdelegate create_connection(map_id, attrs), to: Connections
@doc "Force-create a connection with explicit character ID"
@spec create_connection(String.t(), map(), integer()) ::
{:ok, map()} | {:skip, :exists} | {:error, String.t()}
defdelegate create_connection(map_id, attrs, char_id), to: Connections
@doc "Update a connection"
@spec update_connection(String.t(), String.t(), map()) ::
{:ok, map()} | {:error, String.t()}
defdelegate update_connection(map_id, connection_id, attrs), to: Connections
@doc "Delete a connection"
@spec delete_connection(String.t(), integer(), integer()) :: :ok | {:error, term()}
defdelegate delete_connection(map_id, src_id, tgt_id), to: Connections
@doc "Get a connection by source and target system IDs"
@spec get_connection_by_systems(String.t(), integer(), integer()) :: {:ok, map()} | {:error, String.t()}
defdelegate get_connection_by_systems(map_id, source, target), to: Connections
# -- Structures ------------------------------------------------------------
@doc "List all structures"
@spec list_structures(String.t()) :: [map()]
defdelegate list_structures(map_id), to: Structures
@doc "Create a structure"
@spec create_structure(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
defdelegate create_structure(map_id, params), to: Structures
@doc "Update a structure"
@spec update_structure(String.t(), String.t(), map()) :: {:ok, map()} | {:error, String.t()}
defdelegate update_structure(map_id, struct_id, params), to: Structures
@doc "Delete a structure"
@spec delete_structure(String.t(), String.t()) :: :ok | {:error, String.t()}
defdelegate delete_structure(map_id, struct_id), to: Structures
# -- Signatures ------------------------------------------------------------
@doc "List all signatures"
@spec list_signatures(String.t()) :: [map()]
defdelegate list_signatures(map_id), to: Signatures
@doc "Create a signature"
@spec create_signature(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
defdelegate create_signature(map_id, params), to: Signatures
@doc "Update a signature"
@spec update_signature(String.t(), String.t(), map()) ::
{:ok, map()} | {:error, String.t()}
defdelegate update_signature(map_id, sig_id, params), to: Signatures
@doc "Delete a signature in a map"
@spec delete_signature(String.t(), String.t()) :: :ok | {:error, String.t()}
defdelegate delete_signature(map_id, sig_id), to: Signatures
end

View File

@@ -340,7 +340,7 @@ defmodule WandererApp.Map.SubscriptionManager do
end)
{:error, :no_active_subscription} ->
Logger.warn(
Logger.warning(
"Cannot create license for map #{map.id}: No active subscription found"
)

View File

@@ -196,7 +196,7 @@ defmodule WandererApp.Map.ZkbDataFetcher do
end
end
defp with_started_map(map_id, label \\ "operation", fun) when is_function(fun, 0) do
defp with_started_map(map_id, label, fun) when is_function(fun, 0) do
if WandererApp.Cache.lookup!("map_#{map_id}:started", false) do
fun.()
else

View File

@@ -0,0 +1,259 @@
defmodule WandererApp.Map.Operations.Connections do
@moduledoc """
CRUD and batch upsert for map connections.
"""
alias Ash.Error.Invalid
alias WandererApp.MapConnectionRepo
alias WandererApp.Map.Server
require Logger
@spec list_connections(String.t()) :: [map()] | {:error, atom()}
def list_connections(map_id) do
with {:ok, conns} <- MapConnectionRepo.get_by_map(map_id) do
conns
else
{:error, err} ->
Logger.warning("[list_connections] Repo error: #{inspect(err)}")
{:error, :repo_error}
other ->
Logger.error("[list_connections] Unexpected repo result: #{inspect(other)}")
{:error, :unexpected_repo_result}
end
end
@spec list_connections(String.t(), integer()) :: [map()]
def list_connections(map_id, system_id) do
list_connections(map_id)
|> Enum.filter(fn c ->
c.solar_system_source == system_id or c.solar_system_target == system_id
end)
end
@spec get_connection(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()}
def get_connection(map_id, conn_id) do
case MapConnectionRepo.get_by_id(map_id, conn_id) do
{:ok, conn} -> {:ok, conn}
_ -> {:error, "Connection not found"}
end
end
@spec create_connection(Plug.Conn.t(), map()) :: {:ok, map()} | {:skip, :exists} | {:error, atom()}
def create_connection(%{assigns: %{map_id: map_id, owner_character_id: char_id}} = _conn, attrs) do
do_create(attrs, map_id, char_id)
end
def create_connection(map_id, attrs, char_id) do
do_create(attrs, map_id, char_id)
end
defp do_create(attrs, map_id, char_id) do
with {:ok, source} <- parse_int(attrs["solar_system_source"], "solar_system_source"),
{:ok, target} <- parse_int(attrs["solar_system_target"], "solar_system_target") do
info = %{
solar_system_source_id: source,
solar_system_target_id: target,
character_id: char_id,
type: parse_type(attrs["type"])
}
add_result = Server.add_connection(map_id, info)
case add_result do
:ok -> {:ok, :created}
{:ok, []} ->
Logger.warning("[do_create] Server.add_connection returned :ok, [] for map_id=#{inspect(map_id)}, source=#{inspect(source)}, target=#{inspect(target)}")
{:error, :inconsistent_state}
{:error, %Invalid{errors: errors}} = err ->
if Enum.any?(errors, &is_unique_constraint_error?/1), do: {:skip, :exists}, else: err
{:error, _} = err ->
Logger.error("[do_create] Server.add_connection error: #{inspect(err)}")
{:error, :server_error}
_ ->
Logger.error("[do_create] Unexpected add_result: #{inspect(add_result)}")
{:error, :unexpected_error}
end
else
{:ok, []} ->
Logger.warning("[do_create] Source or target system not found: attrs=#{inspect(attrs)}")
{:error, :inconsistent_state}
{:error, _} = err ->
Logger.error("[do_create] parse_int error: #{inspect(err)}, attrs=#{inspect(attrs)}")
{:error, :parse_error}
_ ->
Logger.error("[do_create] Unexpected error in preconditions: attrs=#{inspect(attrs)}")
{:error, :unexpected_precondition_error}
end
end
@spec update_connection(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
def update_connection(%{assigns: %{map_id: map_id, owner_character_id: char_id}} = _conn, conn_id, attrs) do
with {:ok, conn_struct} <- MapConnectionRepo.get_by_id(map_id, conn_id),
result <- (
try do
_allowed_keys = [
:mass_status,
:ship_size_type,
:type
]
_update_map =
attrs
|> Enum.filter(fn {k, _v} -> k in ["mass_status", "ship_size_type", "type"] end)
|> Enum.map(fn {k, v} -> {String.to_atom(k), v} end)
|> Enum.into(%{})
res = apply_connection_updates(map_id, conn_struct, attrs, char_id)
res
rescue
error ->
Logger.error("[update_connection] Exception: #{inspect(error)}")
{:error, :exception}
end
),
:ok <- result,
{:ok, updated_conn} <- MapConnectionRepo.get_by_id(map_id, conn_id) do
{:ok, updated_conn}
else
{:error, err} -> {:error, err}
_ -> {:error, :unexpected_error}
end
end
def update_connection(_conn, _conn_id, _attrs), do: {:error, :missing_params}
@spec delete_connection(Plug.Conn.t(), integer(), integer()) :: :ok | {:error, atom()}
def delete_connection(%{assigns: %{map_id: map_id}} = _conn, src, tgt) do
case Server.delete_connection(map_id, %{solar_system_source_id: src, solar_system_target_id: tgt}) do
:ok -> :ok
{:error, :not_found} ->
Logger.warning("[delete_connection] Connection not found: source=#{inspect(src)}, target=#{inspect(tgt)}")
{:error, :not_found}
{:error, _} = err ->
Logger.error("[delete_connection] Server error: #{inspect(err)}")
{:error, :server_error}
_ ->
Logger.error("[delete_connection] Unknown error")
{:error, :unknown}
end
end
def delete_connection(_conn, _src, _tgt), do: {:error, :missing_params}
@doc "Batch upsert for connections"
@spec upsert_batch(Plug.Conn.t(), [map()]) :: %{created: integer(), updated: integer(), skipped: integer()}
def upsert_batch(%{assigns: %{map_id: map_id, owner_character_id: char_id}} = conn, conns) do
_assigns = %{map_id: map_id, char_id: char_id}
Enum.reduce(conns, %{created: 0, updated: 0, skipped: 0}, fn conn_attrs, acc ->
case upsert_single(conn, conn_attrs) do
{:ok, :created} -> %{acc | created: acc.created + 1}
{:ok, :updated} -> %{acc | updated: acc.updated + 1}
_ -> %{acc | skipped: acc.skipped + 1}
end
end)
end
def upsert_batch(_conn, _conns), do: %{created: 0, updated: 0, skipped: 0}
@doc "Upsert a single connection"
@spec upsert_single(Plug.Conn.t(), map()) :: {:ok, :created | :updated} | {:error, atom()}
def upsert_single(%{assigns: %{map_id: map_id, owner_character_id: char_id}} = conn, conn_data) do
source = conn_data["solar_system_source"] || conn_data[:solar_system_source]
target = conn_data["solar_system_target"] || conn_data[:solar_system_target]
with {:ok, %{} = existing_conn} <- get_connection_by_systems(map_id, source, target),
{:ok, _} <- update_connection(conn, existing_conn.id, conn_data) do
{:ok, :updated}
else
{:ok, nil} ->
case create_connection(map_id, conn_data, char_id) do
{:ok, _} -> {:ok, :created}
{:skip, :exists} -> {:ok, :updated}
err -> {:error, err}
end
{:error, _} = err ->
Logger.warning("[upsert_single] Connection lookup error: #{inspect(err)}")
{:error, :lookup_error}
err ->
Logger.error("[upsert_single] Update failed: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def upsert_single(_conn, _conn_data), do: {:error, :missing_params}
@doc "Get a connection by source and target system IDs"
@spec get_connection_by_systems(String.t(), integer(), integer()) :: {:ok, map()} | {:error, String.t()}
def get_connection_by_systems(map_id, source, target) do
with {:ok, conn} <- WandererApp.Map.find_connection(map_id, source, target) do
if conn, do: {:ok, conn}, else: WandererApp.Map.find_connection(map_id, target, source)
else
{:error, reason} -> {:error, reason}
end
end
# -- Helpers ---------------------------------------------------------------
defp parse_int(val, _field) when is_integer(val), do: {:ok, val}
defp parse_int(val, field) when is_binary(val) do
case Integer.parse(val) do
{i, _} -> {:ok, i}
_ -> {:error, "Invalid #{field}: #{val}"}
end
end
defp parse_int(nil, field), do: {:error, "Missing #{field}"}
defp parse_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"}
defp parse_type(val) when is_integer(val), do: val
defp parse_type(val) when is_binary(val) do
case Integer.parse(val) do
{i, _} -> i
_ -> 0
end
end
defp parse_type(_), do: 0
defp is_unique_constraint_error?(%{constraint: :unique}), do: true
defp is_unique_constraint_error?(%{constraint: :unique_constraint}), do: true
defp is_unique_constraint_error?(_), do: false
defp apply_connection_updates(map_id, conn, attrs, _char_id) do
Enum.reduce_while(attrs, :ok, fn {key, val}, _acc ->
result =
case key do
"mass_status" -> maybe_update_mass_status(map_id, conn, val)
"ship_size_type" -> maybe_update_ship_size_type(map_id, conn, val)
"type" -> maybe_update_type(map_id, conn, val)
_ -> :ok
end
if result == :ok do
{:cont, :ok}
else
{:halt, result}
end
end)
|> case do
:ok -> :ok
err -> err
end
end
defp maybe_update_mass_status(_map_id, _conn, nil), do: :ok
defp maybe_update_mass_status(map_id, conn, value) do
Server.update_connection_mass_status(map_id, %{
solar_system_source_id: conn.solar_system_source,
solar_system_target_id: conn.solar_system_target,
mass_status: value
})
end
defp maybe_update_ship_size_type(_map_id, _conn, nil), do: :ok
defp maybe_update_ship_size_type(map_id, conn, value) do
Server.update_connection_ship_size_type(map_id, %{
solar_system_source_id: conn.solar_system_source,
solar_system_target_id: conn.solar_system_target,
ship_size_type: value
})
end
defp maybe_update_type(_map_id, _conn, nil), do: :ok
defp maybe_update_type(map_id, conn, value) do
Server.update_connection_type(map_id, %{
solar_system_source_id: conn.solar_system_source,
solar_system_target_id: conn.solar_system_target,
type: value
})
end
end

View File

@@ -0,0 +1,75 @@
defmodule WandererApp.Map.Operations.Owner do
@moduledoc """
Handles fetching and caching of the main character info for a map owner.
"""
# Cache TTL in milliseconds (24 hours)
@owner_info_cache_ttl 86_400_000
alias WandererApp.{
MapRepo,
MapCharacterSettingsRepo,
MapUserSettingsRepo,
Cache
}
alias WandererApp.Character
alias WandererApp.Character.TrackingUtils
@spec get_owner_character_id(String.t()) :: {:ok, %{id: term(), user_id: term()}} | {:error, String.t()}
def get_owner_character_id(map_id) do
cache_key = "map_#{map_id}:owner_info"
case Cache.lookup!(cache_key) do
nil ->
with {:ok, owner} <- fetch_map_owner(map_id),
{:ok, char_ids} <- fetch_character_ids(map_id),
{:ok, characters} <- load_characters(char_ids),
{:ok, user_settings} <- MapUserSettingsRepo.get(map_id, owner.id),
{:ok, main} <- TrackingUtils.get_main_character(user_settings, characters, characters) do
result = %{id: main.id, user_id: main.user_id}
Cache.insert(cache_key, result, ttl: @owner_info_cache_ttl)
{:ok, result}
else
{:error, msg} -> {:error, msg}
_ -> {:error, "Failed to resolve main character"}
end
cached ->
{:ok, cached}
end
end
defp fetch_map_owner(map_id) do
case MapRepo.get(map_id, [:owner]) do
{:ok, %{owner: %_{} = owner}} -> {:ok, owner}
{:ok, %{owner: nil}} -> {:error, "Map has no owner"}
{:error, _} -> {:error, "Map not found"}
end
end
defp fetch_character_ids(map_id) do
case MapCharacterSettingsRepo.get_all_by_map(map_id) do
{:ok, settings} when is_list(settings) and settings != [] ->
{:ok, Enum.map(settings, & &1.character_id)}
{:ok, []} ->
{:error, "No character settings found"}
{:error, _} ->
{:error, "Failed to fetch character settings"}
end
end
defp load_characters(ids) when is_list(ids) do
ids
|> Enum.map(&Character.get_character/1)
|> Enum.flat_map(fn
{:ok, ch} -> [ch]
_ -> []
end)
|> case do
[] -> {:error, "No valid characters found"}
chars -> {:ok, chars}
end
end
end

View File

@@ -0,0 +1,114 @@
defmodule WandererApp.Map.Operations.Signatures do
@moduledoc """
CRUD for map signatures.
"""
require Logger
alias WandererApp.Map.Operations
alias WandererApp.Api.{MapSystem, MapSystemSignature}
alias WandererApp.Map.Server
@spec list_signatures(String.t()) :: [map()]
def list_signatures(map_id) do
systems = Operations.list_systems(map_id)
if systems != [] do
systems
|> Enum.flat_map(fn sys ->
with {:ok, sigs} <- MapSystemSignature.by_system_id(sys.id) do
sigs
else
err ->
Logger.error("[list_signatures] error: #{inspect(err)}")
[]
end
end)
else
[]
end
end
@spec create_signature(Plug.Conn.t(), map()) :: {:ok, map()} | {:error, atom()}
def create_signature(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, %{"solar_system_id" => _solar_system_id} = params) do
attrs = Map.put(params, "character_eve_id", char_id)
case Server.update_signatures(map_id, %{
added_signatures: [attrs],
updated_signatures: [],
removed_signatures: [],
solar_system_id: params["solar_system_id"],
character_id: char_id,
user_id: user_id,
delete_connection_with_sigs: false
}) do
:ok -> {:ok, attrs}
err ->
Logger.error("[create_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def create_signature(_conn, _params), do: {:error, :missing_params}
@spec update_signature(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
def update_signature(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, sig_id, params) do
with {:ok, sig} <- MapSystemSignature.by_id(sig_id),
{:ok, system} <- MapSystem.by_id(sig.system_id) do
base = %{
"eve_id" => sig.eve_id,
"name" => sig.name,
"kind" => sig.kind,
"group" => sig.group,
"type" => sig.type,
"custom_info" => sig.custom_info,
"character_eve_id" => char_id,
"description" => sig.description,
"linked_system_id" => sig.linked_system_id
}
attrs = Map.merge(base, params)
:ok = Server.update_signatures(map_id, %{
added_signatures: [],
updated_signatures: [attrs],
removed_signatures: [],
solar_system_id: system.solar_system_id,
character_id: char_id,
user_id: user_id,
delete_connection_with_sigs: false
})
{:ok, attrs}
else
err ->
Logger.error("[update_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def update_signature(_conn, _sig_id, _params), do: {:error, :missing_params}
@spec delete_signature(Plug.Conn.t(), String.t()) :: :ok | {:error, atom()}
def delete_signature(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, sig_id) do
with {:ok, sig} <- MapSystemSignature.by_id(sig_id),
{:ok, system} <- MapSystem.by_id(sig.system_id) do
removed = [%{
"eve_id" => sig.eve_id,
"name" => sig.name,
"kind" => sig.kind,
"group" => sig.group
}]
:ok = Server.update_signatures(map_id, %{
added_signatures: [],
updated_signatures: [],
removed_signatures: removed,
solar_system_id: system.solar_system_id,
character_id: char_id,
user_id: user_id,
delete_connection_with_sigs: false
})
:ok
else
err ->
Logger.error("[delete_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def delete_signature(_conn, _sig_id), do: {:error, :missing_params}
end

View File

@@ -0,0 +1,104 @@
defmodule WandererApp.Map.Operations.Structures do
@moduledoc """
CRUD for map structures.
"""
alias WandererApp.Map.Operations
alias WandererApp.Api.MapSystem
alias WandererApp.Api.MapSystemStructure
alias WandererApp.Structure
require Logger
@spec list_structures(String.t()) :: [map()]
def list_structures(map_id) do
with systems when is_list(systems) and systems != [] <- (
case Operations.list_systems(map_id) do
{:ok, systems} -> systems
systems when is_list(systems) -> systems
_ -> []
end
) do
systems
|> Enum.flat_map(fn sys ->
with {:ok, structs} <- MapSystemStructure.by_system_id(sys.id) do
structs
else
_other -> []
end
end)
else
_ -> []
end
end
@spec create_structure(Plug.Conn.t(), map()) :: {:ok, map()} | {:error, atom()}
def create_structure(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, %{"solar_system_id" => _solar_system_id} = params) do
with {:ok, system} <- MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: params["solar_system_id"]}),
attrs <- Map.put(prepare_attrs(params), "system_id", system.id),
:ok <- Structure.update_structures(system, [attrs], [], [], char_id, user_id),
name = Map.get(attrs, "name"),
structure_type_id = Map.get(attrs, "structureTypeId"),
struct when not is_nil(struct) <-
MapSystemStructure.by_system_id!(system.id)
|> Enum.find(fn s -> s.name == name and s.structure_type_id == structure_type_id end) do
{:ok, struct}
else
nil ->
Logger.warning("[create_structure] Structure not found after creation")
{:error, :structure_not_found}
err ->
Logger.error("[create_structure] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def create_structure(_conn, _params), do: {:error, "missing params"}
@spec update_structure(Plug.Conn.t(), String.t(), map()) :: {:ok, map()} | {:error, atom()}
def update_structure(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, struct_id, params) do
with {:ok, struct} <- MapSystemStructure.by_id(struct_id),
{:ok, system} <- MapSystem.read_by_map_and_solar_system(%{map_id: map_id, solar_system_id: struct.solar_system_id}) do
attrs = Map.merge(prepare_attrs(params), %{"id" => struct_id})
:ok = Structure.update_structures(system, [], [attrs], [], char_id, user_id)
case MapSystemStructure.by_id(struct_id) do
{:ok, updated} -> {:ok, updated}
err ->
Logger.error("[update_structure] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
else
err ->
Logger.error("[update_structure] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def update_structure(_conn, _struct_id, _params), do: {:error, "missing params"}
@spec delete_structure(Plug.Conn.t(), String.t()) :: :ok | {:error, atom()}
def delete_structure(%{assigns: %{map_id: _map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, struct_id) do
with {:ok, struct} <- MapSystemStructure.by_id(struct_id),
{:ok, system} <- MapSystem.by_id(struct.system_id) do
:ok = Structure.update_structures(system, [], [], [%{"id" => struct_id}], char_id, user_id)
:ok
else
err ->
Logger.error("[delete_structure] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error}
end
end
def delete_structure(_conn, _struct_id), do: {:error, "missing params"}
defp prepare_attrs(params) do
params
|> Enum.map(fn
{"structure_type", v} -> {"structureType", v}
{"structure_type_id", v} -> {"structureTypeId", v}
{"end_time", v} -> {"endTime", v}
{k, v} -> {k, v}
end)
|> Map.new()
|> Map.take(["name", "structureType", "structureTypeId", "status", "notes", "endTime"])
end
end

View File

@@ -0,0 +1,195 @@
defmodule WandererApp.Map.Operations.Systems do
@moduledoc """
CRUD and batch upsert for map systems.
"""
alias WandererApp.MapSystemRepo
alias WandererApp.Map.Server
alias WandererApp.Map.Operations.Connections
require Logger
@spec list_systems(String.t()) :: [map()]
def list_systems(map_id) do
with {:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do
systems
else
_ -> []
end
end
@spec get_system(String.t(), integer()) :: {:ok, map()} | {:error, :not_found}
def get_system(map_id, system_id) do
MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id)
end
@spec create_system(Plug.Conn.t(), map()) :: {:ok, map()} | {:error, atom()}
def create_system(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, params) do
do_create_system(map_id, user_id, char_id, params)
end
def create_system(_conn, _params), do: {:error, :missing_params}
# Private helper for batch upsert
defp create_system_batch(%{map_id: map_id, user_id: user_id, char_id: char_id}, params) do
do_create_system(map_id, user_id, char_id, params)
end
defp do_create_system(map_id, user_id, char_id, params) do
with {:ok, system_id} <- fetch_system_id(params),
coords <- normalize_coordinates(params),
:ok <- Server.add_system(map_id, %{solar_system_id: system_id, coordinates: coords}, user_id, char_id),
{:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) do
{:ok, system}
else
{:error, reason} when is_binary(reason) ->
Logger.warning("[do_create_system] Expected error: #{inspect(reason)}")
{:error, :expected_error}
_ ->
Logger.error("[do_create_system] Unexpected error")
{:error, :unexpected_error}
end
end
@spec update_system(Plug.Conn.t(), integer(), map()) :: {:ok, map()} | {:error, atom()}
def update_system(%{assigns: %{map_id: map_id}} = _conn, system_id, attrs) do
with {:ok, current} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id),
x_raw <- Map.get(attrs, "position_x", Map.get(attrs, :position_x, current.position_x)),
y_raw <- Map.get(attrs, "position_y", Map.get(attrs, :position_y, current.position_y)),
{:ok, x} <- parse_int(x_raw, "position_x"),
{:ok, y} <- parse_int(y_raw, "position_y"),
coords = %{x: x, y: y},
:ok <- apply_system_updates(map_id, system_id, attrs, coords),
{:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) do
{:ok, system}
else
{:error, reason} when is_binary(reason) ->
Logger.warning("[update_system] Expected error: #{inspect(reason)}")
{:error, :expected_error}
_ ->
Logger.error("[update_system] Unexpected error")
{:error, :unexpected_error}
end
end
def update_system(_conn, _system_id, _attrs), do: {:error, :missing_params}
@spec delete_system(Plug.Conn.t(), integer()) :: {:ok, integer()} | {:error, atom()}
def delete_system(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = _conn, system_id) do
with {:ok, _} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id),
:ok <- Server.delete_systems(map_id, [system_id], user_id, char_id) do
{:ok, 1}
else
{:error, :not_found} ->
Logger.warning("[delete_system] System not found: #{inspect(system_id)}")
{:error, :not_found}
_ ->
Logger.error("[delete_system] Unexpected error")
{:error, :unexpected_error}
end
end
def delete_system(_conn, _system_id), do: {:error, :missing_params}
@spec upsert_systems_and_connections(Plug.Conn.t(), [map()], [map()]) :: {:ok, map()} | {:error, atom()}
def upsert_systems_and_connections(%{assigns: %{map_id: map_id, owner_character_id: char_id, owner_user_id: user_id}} = conn, systems, connections) do
assigns = %{map_id: map_id, user_id: user_id, char_id: char_id}
{created_s, updated_s, _skipped_s} = upsert_each(systems, fn sys -> create_system_batch(assigns, sys) end, 0, 0, 0)
conn_results =
connections
|> Enum.reduce(%{created: 0, updated: 0, skipped: 0}, fn conn_data, acc ->
case Connections.upsert_single(conn, conn_data) do
{:ok, :created} -> %{acc | created: acc.created + 1}
{:ok, :updated} -> %{acc | updated: acc.updated + 1}
_ -> %{acc | skipped: acc.skipped + 1}
end
end)
{:ok, %{
systems: %{created: created_s, updated: updated_s},
connections: %{created: conn_results.created, updated: conn_results.updated}
}}
end
def upsert_systems_and_connections(_conn, _systems, _connections), do: {:error, :missing_params}
# -- Internal Helpers -------------------------------------------------------
defp fetch_system_id(%{"solar_system_id" => id}), do: parse_int(id, "solar_system_id")
defp fetch_system_id(%{solar_system_id: id}) when not is_nil(id), do: parse_int(id, "solar_system_id")
defp fetch_system_id(_), do: {:error, "Missing system identifier (id)"}
defp parse_int(val, _field) when is_integer(val), do: {:ok, val}
defp parse_int(val, field) when is_binary(val) do
case Integer.parse(val) do
{i, _} -> {:ok, i}
_ -> {:error, "Invalid #{field}: #{val}"}
end
end
defp parse_int(nil, field), do: {:error, "Missing #{field}"}
defp parse_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"}
defp normalize_coordinates(%{"coordinates" => %{"x" => x, "y" => y}}) when is_number(x) and is_number(y),
do: %{x: x, y: y}
defp normalize_coordinates(%{coordinates: %{x: x, y: y}}) when is_number(x) and is_number(y),
do: %{x: x, y: y}
defp normalize_coordinates(params) do
%{
x: params |> Map.get("position_x", Map.get(params, :position_x, 0)),
y: params |> Map.get("position_y", Map.get(params, :position_y, 0))
}
end
defp apply_system_updates(map_id, system_id, attrs, %{x: x, y: y}) do
with :ok <- Server.update_system_position(map_id, %{solar_system_id: system_id, position_x: round(x), position_y: round(y)}) do
attrs
|> Map.drop([:coordinates, :position_x, :position_y, :solar_system_id,
"coordinates", "position_x", "position_y", "solar_system_id"])
|> Enum.reduce_while(:ok, fn {key, val}, _ ->
case update_system_field(map_id, system_id, to_string(key), val) do
:ok -> {:cont, :ok}
err -> {:halt, err}
end
end)
end
end
defp update_system_field(map_id, system_id, field, val) do
case field do
"status" -> Server.update_system_status(map_id, %{solar_system_id: system_id, status: convert_status(val)})
"description" -> Server.update_system_description(map_id, %{solar_system_id: system_id, description: val})
"tag" -> Server.update_system_tag(map_id, %{solar_system_id: system_id, tag: val})
"locked" ->
bool = val in [true, "true", 1, "1"]
Server.update_system_locked(map_id, %{solar_system_id: system_id, locked: bool})
f when f in ["label", "labels"] ->
labels = cond do
is_list(val) -> val
is_binary(val) -> String.split(val, ",", trim: true)
true -> []
end
Server.update_system_labels(map_id, %{solar_system_id: system_id, labels: Enum.join(labels, ",")})
"temporary_name" -> Server.update_system_temporary_name(map_id, %{solar_system_id: system_id, temporary_name: val})
_ -> :ok
end
end
defp convert_status("CLEAR"), do: 0
defp convert_status("DANGEROUS"), do: 1
defp convert_status("OCCUPIED"), do: 2
defp convert_status("MASS_CRITICAL"), do: 3
defp convert_status("TIME_CRITICAL"), do: 4
defp convert_status("REINFORCED"), do: 5
defp convert_status(i) when is_integer(i), do: i
defp convert_status(s) when is_binary(s) do
case Integer.parse(s) do
{i, _} -> i
_ -> 0
end
end
defp convert_status(_), do: 0
defp upsert_each([], _fun, c, u, d), do: {c, u, d}
defp upsert_each([item | rest], fun, c, u, d) do
case fun.(item) do
{:ok, _} -> upsert_each(rest, fun, c + 1, u, d)
:ok -> upsert_each(rest, fun, c + 1, u, d)
{:skip, _} -> upsert_each(rest, fun, c, u + 1, d)
_ -> upsert_each(rest, fun, c, u, d + 1)
end
end
end

View File

@@ -87,14 +87,11 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
:timer.minutes(get_eol_expire_timeout_mins())
def init_eol_cache(map_id, connections_eol_time) do
eol_expire_timeout = get_eol_expire_timeout()
connections_eol_time
|> Enum.each(fn {connection_id, connection_eol_time} ->
WandererApp.Cache.put(
"map_#{map_id}:conn_#{connection_id}:mark_eol_time",
connection_eol_time,
ttl: eol_expire_timeout
connection_eol_time
)
end)
end
@@ -178,8 +175,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
true ->
WandererApp.Cache.put(
"map_#{map_id}:conn_#{connection_id}:mark_eol_time",
DateTime.utc_now(),
ttl: get_eol_expire_timeout()
DateTime.utc_now()
)
_ ->

View File

@@ -95,4 +95,12 @@ defmodule WandererApp.MapConnectionRepo do
do:
connection
|> WandererApp.Api.MapConnection.update_custom_info(update)
def get_by_id(map_id, id) do
case WandererApp.Api.MapConnection.by_id(id) do
{:ok, conn} when conn.map_id == map_id -> {:ok, conn}
{:ok, _} -> {:error, :not_found}
{:error, _} -> {:error, :not_found}
end
end
end

View File

@@ -7,7 +7,8 @@ defmodule WandererApp.Structure do
alias WandererApp.Api.MapSystemStructure
alias WandererApp.Character
def update_structures(system, added, updated, removed, main_character_eve_id) do
def update_structures(system, added, updated, removed, main_character_eve_id, user_id \\ nil) do
Logger.info("[Structure] update_structures called by user_id=#{inspect(user_id)}")
added_structs =
parse_structures(added, main_character_eve_id, system)
|> Enum.map(&Map.delete(&1, :id))
@@ -105,7 +106,28 @@ defmodule WandererApp.Structure do
# remove PK so Ash doesn't treat it as a new record
updated_data = Map.delete(updated_data, :id)
new_record = MapSystemStructure.update(existing, updated_data)
# Merge update data with existing record to avoid nil required fields
merged_data = Map.merge(Map.from_struct(existing), updated_data, fn _k, v1, v2 -> if is_nil(v2), do: v1, else: v2 end)
# Only keep fields accepted by Ash update action
allowed_keys = [
:system_id,
:solar_system_name,
:solar_system_id,
:structure_type_id,
:structure_type,
:character_eve_id,
:name,
:notes,
:owner_name,
:owner_ticker,
:owner_id,
:status,
:end_time
]
filtered_data = Map.take(merged_data, allowed_keys)
Logger.info("[Structure] update_structures_in_db: calling update for id=#{existing.id} with: #{inspect(filtered_data)}")
new_record = MapSystemStructure.update(existing, filtered_data)
Logger.info("[Structure] update_structures_in_db: update result for id=#{existing.id}: #{inspect(new_record)}")
Logger.debug(fn ->
"[Structure] updated record =>\n" <> inspect(new_record, pretty: true)

View File

@@ -162,7 +162,7 @@ defmodule WandererApp.Zkb.KillsPreloader do
"[KillsPreloader] Starting #{pass_type} pass => #{length(unique_systems)} systems"
)
{final_state, kills_map} =
{final_state, _kills_map} =
unique_systems
|> Task.async_stream(
fn {_map_id, system_id} ->

View File

@@ -23,7 +23,9 @@ defmodule WandererApp.Zkb.Supervisor do
},
opts: [
name: {:local, :zkb_kills_provider},
mint_upgrade_opts: [Mint.WebSocket.PerMessageDeflate]
reconnect: true,
reconnect_after: 5_000,
max_reconnects: :infinity
]
},
preloader_child

View File

@@ -11,7 +11,6 @@ defmodule WandererApp.Zkb.KillsProvider.Websocket do
use Retry
@heartbeat_interval 1_000
@max_esi_retries 3
# Called by `KillsProvider.handle_connect`
def handle_connect(_status, _headers, %{connected: _} = state) do

View File

@@ -27,6 +27,5 @@ defmodule WandererAppWeb.ApiSpec do
},
security: [%{"bearerAuth" => []}]
}
|> OpenApiSpex.resolve_schema_modules()
end
end

View File

@@ -13,7 +13,7 @@ defmodule WandererAppWeb.MapAccessListAPIController do
use OpenApiSpex.ControllerSpecs
alias WandererApp.Api.{AccessList, Character}
alias WandererAppWeb.UtilAPIController, as: Util
alias WandererAppWeb.Helpers.APIUtils
import Ash.Query
require Logger
@@ -245,7 +245,7 @@ defmodule WandererAppWeb.MapAccessListAPIController do
}}
]
def index(conn, params) do
case Util.fetch_map_id(params) do
case APIUtils.fetch_map_id(params) do
{:ok, map_identifier} ->
with {:ok, map} <- get_map(map_identifier),
{:ok, loaded_map} <- Ash.load(map, acls: [:owner]) do
@@ -320,7 +320,7 @@ defmodule WandererAppWeb.MapAccessListAPIController do
}}
]
def create(conn, params) do
with {:ok, map_identifier} <- Util.fetch_map_id(params),
with {:ok, map_identifier} <- APIUtils.fetch_map_id(params),
{:ok, map} <- get_map(map_identifier),
%{"acl" => acl_params} <- params,
owner_eve_id when not is_nil(owner_eve_id) <- Map.get(acl_params, "owner_eve_id"),

View File

@@ -3,8 +3,7 @@ defmodule WandererAppWeb.CommonAPIController do
use OpenApiSpex.ControllerSpecs
alias WandererApp.CachedInfo
alias WandererAppWeb.UtilAPIController, as: Util
alias WandererApp.EveDataService
alias WandererAppWeb.Helpers.APIUtils
@system_static_response_schema %OpenApiSpex.Schema{
type: :object,
@@ -87,8 +86,8 @@ defmodule WandererAppWeb.CommonAPIController do
}
]
def show_system_static(conn, params) do
with {:ok, solar_system_str} <- Util.require_param(params, "id"),
{:ok, solar_system_id} <- Util.parse_int(solar_system_str) do
with {:ok, solar_system_str} <- APIUtils.require_param(params, "id"),
{:ok, solar_system_id} <- APIUtils.parse_int(solar_system_str) do
case CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system} ->
# Get basic system data
@@ -113,11 +112,6 @@ defmodule WandererAppWeb.CommonAPIController do
end
end
@doc """
Converts a system map to a JSON-friendly format.
Takes only the fields that are needed for the API response.
"""
defp static_system_to_json(system) do
system
|> Map.take([
@@ -142,12 +136,6 @@ defmodule WandererAppWeb.CommonAPIController do
])
end
@doc """
Enhances system data with wormhole type information.
If the system has static wormholes, adds detailed information about each static.
Otherwise, returns the original data unchanged.
"""
defp enhance_with_static_details(data) do
if data[:statics] && length(data[:statics]) > 0 do
# Add the enhanced static details to the response
@@ -158,11 +146,6 @@ defmodule WandererAppWeb.CommonAPIController do
end
end
@doc """
Gets detailed information for each static wormhole.
Uses the CachedInfo to get both wormhole type data and wormhole class data.
"""
defp get_static_details(statics) do
# Get wormhole data from CachedInfo
{:ok, wormhole_types} = CachedInfo.get_wormhole_types()
@@ -186,12 +169,6 @@ defmodule WandererAppWeb.CommonAPIController do
end)
end
@doc """
Creates detailed wormhole information when the wormhole type is found.
Includes information about the destination and properties of the wormhole.
Ensures that destination.id is always a string to match the OpenAPI schema.
"""
defp create_wormhole_details(wh_type, classes_by_id) do
# Get destination class info
dest_class = Map.get(classes_by_id, wh_type.dest)
@@ -213,11 +190,6 @@ defmodule WandererAppWeb.CommonAPIController do
}
end
@doc """
Creates fallback information when a wormhole type is not found.
Provides a placeholder structure with nil values for unknown wormhole types.
"""
defp create_fallback_wormhole_details(static_name) do
%{
name: static_name,

View File

@@ -0,0 +1,50 @@
defmodule WandererAppWeb.FallbackController do
use WandererAppWeb, :controller
alias WandererAppWeb.Helpers.APIUtils
# Handles not_found errors from with/else
def call(conn, {:error, :not_found}) do
APIUtils.error_response(conn, :not_found, "Not found", "The requested resource could not be found")
end
# Handles invalid_id errors
def call(conn, {:error, :invalid_id}) do
APIUtils.error_response(conn, :bad_request, "Invalid system ID")
end
# Handles invalid_coordinates_format errors
def call(conn, {:error, :invalid_coordinates_format}) do
APIUtils.error_response(conn, :bad_request, "Invalid coordinates format. Use %{\"coordinates\" => %{\"x\" => number, \"y\" => number}}")
end
# Handles not_associated errors
def call(conn, {:error, :not_associated}) do
APIUtils.error_response(conn, :not_found, "Connection not associated with specified system")
end
# Handles not_involved errors
def call(conn, {:error, :not_involved}) do
APIUtils.error_response(conn, :bad_request, "Connection must involve specified system")
end
# Handles creation_failed errors
def call(conn, {:error, :creation_failed}) do
APIUtils.error_response(conn, :internal_server_error, "Failed to create resource")
end
# Handles deletion_failed errors
def call(conn, {:error, :deletion_failed}) do
APIUtils.error_response(conn, :internal_server_error, "Failed to delete resource")
end
# Handles any other {:error, message} returns
def call(conn, {:error, msg}) when is_binary(msg) do
APIUtils.error_response(conn, :bad_request, msg)
end
# Handles any other unmatched errors
def call(conn, _error) do
APIUtils.error_response(conn, :internal_server_error, "An unexpected error occurred")
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
alias WandererApp.Zkb.KillsProvider.KillsCache
alias WandererAppWeb.UtilAPIController, as: Util
alias WandererAppWeb.Helpers.APIUtils
# -----------------------------------------------------------------
# Inline Schemas
@@ -117,8 +117,8 @@ defmodule WandererAppWeb.MapAuditAPIController do
)
def index(conn, params) do
with {:ok, map_id} <- Util.fetch_map_id(params),
{:ok, period} <- Util.require_param(params, "period"),
with {:ok, map_id} <- APIUtils.fetch_map_id(params),
{:ok, period} <- APIUtils.require_param(params, "period"),
query <- WandererApp.Map.Audit.get_activity_query(map_id, period, "all"),
{:ok, data} <-
Api.read(query) do

View File

@@ -0,0 +1,453 @@
# lib/wanderer_app_web/controllers/map_connection_api_controller.ex
defmodule WandererAppWeb.MapConnectionAPIController do
@moduledoc """
API controller for managing map connections.
Provides operations to list, show, create, delete, and batch-delete connections, with legacy routing support.
"""
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
require Logger
alias OpenApiSpex.Schema
alias WandererApp.Map, as: MapData
alias WandererApp.Map.Operations
alias WandererAppWeb.Helpers.APIUtils
alias WandererAppWeb.Schemas.ResponseSchemas
action_fallback WandererAppWeb.FallbackController
# -- JSON Schemas --
@connection_request_schema %Schema{
type: :object,
properties: %{
solar_system_source: %Schema{type: :integer, description: "Source system ID"},
solar_system_target: %Schema{type: :integer, description: "Target system ID"},
type: %Schema{type: :integer, description: "Connection type (default 0)"},
mass_status: %Schema{type: :integer, description: "Mass status (0-3)", nullable: true},
time_status: %Schema{type: :integer, description: "Time status (0-3)", nullable: true},
ship_size_type: %Schema{type: :integer, description: "Ship size limit (0-3)", nullable: true},
locked: %Schema{type: :boolean, description: "Locked flag", nullable: true},
custom_info: %Schema{type: :string, nullable: true, description: "Optional metadata"},
wormhole_type: %Schema{type: :string, nullable: true, description: "Wormhole code"}
},
required: ~w(solar_system_source solar_system_target)a,
example: %{
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0,
mass_status: 1,
time_status: 2,
ship_size_type: 1,
locked: false,
custom_info: "Frigate only",
wormhole_type: "C2"
}
}
@list_response_schema %Schema{
type: :object,
properties: %{
data: %Schema{
type: :array,
items: %Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
map_id: %Schema{type: :string},
solar_system_source: %Schema{type: :integer},
solar_system_target: %Schema{type: :integer},
type: %Schema{type: :integer},
mass_status: %Schema{type: :integer},
time_status: %Schema{type: :integer},
ship_size_type: %Schema{type: :integer},
locked: %Schema{type: :boolean},
custom_info: %Schema{type: :string, nullable: true},
wormhole_type: %Schema{type: :string, nullable: true}
}
}
}
},
example: %{
data: [
%{
id: "conn-uuid-1",
map_id: "map-uuid-1",
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0,
mass_status: 1,
time_status: 2,
ship_size_type: 1,
locked: false,
custom_info: "Frigate only",
wormhole_type: "C2"
}
]
}
}
@detail_response_schema %Schema{
type: :object,
properties: %{
data: %Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
map_id: %Schema{type: :string},
solar_system_source: %Schema{type: :integer},
solar_system_target: %Schema{type: :integer},
type: %Schema{type: :integer},
mass_status: %Schema{type: :integer},
time_status: %Schema{type: :integer},
ship_size_type: %Schema{type: :integer},
locked: %Schema{type: :boolean},
custom_info: %Schema{type: :string, nullable: true},
wormhole_type: %Schema{type: :string, nullable: true}
}
}
},
example: %{
data: %{
id: "conn-uuid-1",
map_id: "map-uuid-1",
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0,
mass_status: 1,
time_status: 2,
ship_size_type: 1,
locked: false,
custom_info: "Frigate only",
wormhole_type: "C2"
}
}
}
# -- Actions --
operation :index,
summary: "List Map Connections",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "map-slug or map UUID"
],
solar_system_source: [in: :query, type: :integer, required: false],
solar_system_target: [in: :query, type: :integer, required: false]
],
responses: [
ok: {
"List Map Connections",
"application/json",
@list_response_schema
}
]
def index(%{assigns: %{map_id: map_id}} = conn, params) do
with {:ok, src_filter} <- parse_optional(params, "solar_system_source"),
{:ok, tgt_filter} <- parse_optional(params, "solar_system_target") do
conns = MapData.list_connections!(map_id)
conns =
conns
|> filter_by_source(src_filter)
|> filter_by_target(tgt_filter)
data = Enum.map(conns, &APIUtils.connection_to_json/1)
APIUtils.respond_data(conn, data)
else
{:error, msg} when is_binary(msg) ->
conn
|> Plug.Conn.put_status(:bad_request)
|> APIUtils.error_response(:bad_request, msg)
{:error, _} ->
conn
|> Plug.Conn.put_status(:bad_request)
|> APIUtils.error_response(:bad_request, "Invalid filter parameter")
end
end
defp parse_optional(params, key) do
case Map.get(params, key) do
nil -> {:ok, nil}
val -> APIUtils.parse_int(val)
end
end
defp filter_by_source(conns, nil), do: conns
defp filter_by_source(conns, s), do: Enum.filter(conns, &(&1.solar_system_source == s))
defp filter_by_target(conns, nil), do: conns
defp filter_by_target(conns, t), do: Enum.filter(conns, &(&1.solar_system_target == t))
operation :show,
summary: "Show Connection (by id or by source/target)",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "map-slug or map UUID"
],
id: [in: :path, type: :string, required: false],
solar_system_source: [in: :query, type: :integer, required: false],
solar_system_target: [in: :query, type: :integer, required: false]
],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
case Operations.get_connection(map_id, id) do
{:ok, conn_struct} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(conn_struct))
err -> err
end
end
def show(%{assigns: %{map_id: map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do
with {:ok, source} <- APIUtils.parse_int(src),
{:ok, target} <- APIUtils.parse_int(tgt),
{:ok, conn_struct} <- Operations.get_connection_by_systems(map_id, source, target) do
APIUtils.respond_data(conn, APIUtils.connection_to_json(conn_struct))
else
err -> err
end
end
operation :create,
summary: "Create Connection",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "map-slug or map UUID"
],
system_id: [in: :path, type: :string, required: false]
],
request_body: {"Connection create", "application/json", @connection_request_schema},
responses: ResponseSchemas.create_responses(@detail_response_schema)
def create(conn, params) do
case Operations.create_connection(conn, params) do
{:ok, conn_struct} when is_map(conn_struct) ->
conn
|> APIUtils.respond_data(APIUtils.connection_to_json(conn_struct), :created)
{:ok, :created} ->
conn
|> put_status(:created)
|> json(%{data: %{result: "created"}})
{:skip, :exists} ->
conn
|> put_status(:ok)
|> json(%{data: %{result: "exists"}})
{:error, reason} ->
conn
|> put_status(:bad_request)
|> json(%{error: reason})
_other ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Unexpected error"})
end
end
operation :delete,
summary: "Delete Connection (by id or by source/target)",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "map-slug or map UUID"
],
id: [in: :path, type: :string, required: false],
solar_system_source: [in: :query, type: :integer, required: false],
solar_system_target: [in: :query, type: :integer, required: false]
],
responses: ResponseSchemas.delete_responses(nil)
def delete(%{assigns: %{map_id: _map_id}} = conn, %{"id" => id}) do
delete_connection_id(conn, id)
end
def delete(%{assigns: %{map_id: _map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do
delete_by_systems(conn, src, tgt)
end
# Private helpers for delete/2
defp delete_connection_id(conn, id) do
case Operations.get_connection(conn, id) do
{:ok, conn_struct} ->
source_id = conn_struct.solar_system_source
target_id = conn_struct.solar_system_target
case Operations.delete_connection(conn, source_id, target_id) do
:ok -> {:ok, conn_struct}
error -> error
end
_ -> {:error, :invalid_id}
end
end
defp delete_by_systems(conn, src, tgt) do
with {:ok, source} <- APIUtils.parse_int(src),
{:ok, target} <- APIUtils.parse_int(tgt) do
do_delete_by_systems(conn, source, target, src, tgt)
else
{:error, :not_found} ->
Logger.error("[delete_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)}")
{:error, :not_found}
{:error, reason} ->
Logger.error("[delete_connection] Error: #{inspect(reason)}")
{:error, reason}
error ->
Logger.error("[delete_connection] Unexpected error: #{inspect(error)}")
{:error, :internal_server_error}
end
end
defp do_delete_by_systems(conn, source, target, src, tgt) do
map_id = conn.assigns.map_id
case Operations.get_connection_by_systems(map_id, source, target) do
{:ok, nil} ->
Logger.error("[delete_connection] No connection found for source=#{inspect(source)}, target=#{inspect(target)}")
try_reverse_delete(conn, source, target, src, tgt)
{:ok, conn_struct} ->
case Operations.delete_connection(conn, conn_struct.solar_system_source, conn_struct.solar_system_target) do
:ok -> send_resp(conn, :no_content, "")
error -> {:error, error}
end
{:error, _} ->
try_reverse_delete(conn, source, target, src, tgt)
end
end
defp try_reverse_delete(conn, source, target, src, tgt) do
map_id = conn.assigns.map_id
case Operations.get_connection_by_systems(map_id, target, source) do
{:ok, nil} ->
Logger.error("[delete_connection] No connection found for source=#{inspect(target)}, target=#{inspect(source)}")
{:error, :not_found}
{:ok, conn_struct} ->
case Operations.delete_connection(conn, conn_struct.solar_system_source, conn_struct.solar_system_target) do
:ok -> send_resp(conn, :no_content, "")
error -> {:error, error}
end
{:error, reason} ->
Logger.error("[delete_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)} (both orders)")
{:error, reason}
end
end
operation :update,
summary: "Update Connection (by id or by source/target)",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "map-slug or map UUID"
],
id: [in: :path, type: :string, required: false],
solar_system_source: [in: :query, type: :integer, required: false],
solar_system_target: [in: :query, type: :integer, required: false]
],
request_body: {"Connection update", "application/json", @connection_request_schema},
responses: ResponseSchemas.standard_responses(@detail_response_schema)
def update(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
allowed_fields = ["mass_status", "ship_size_type", "locked", "custom_info", "type"]
attrs =
conn.body_params
|> Map.take(allowed_fields)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
update_by_id(conn, map_id, id, attrs)
end
def update(%{assigns: %{map_id: map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do
allowed_fields = ["mass_status", "ship_size_type", "locked", "custom_info", "type"]
attrs =
conn.body_params
|> Map.take(allowed_fields)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
update_by_systems(conn, map_id, src, tgt, attrs)
end
# Private helpers for update/2
defp update_by_id(conn, _map_id, id, attrs) do
case Operations.update_connection(conn, id, attrs) do
{:ok, updated_conn} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn))
err -> err
end
end
defp update_by_systems(conn, _map_id, src, tgt, attrs) do
require Logger
with {:ok, source} <- APIUtils.parse_int(src),
{:ok, target} <- APIUtils.parse_int(tgt) do
do_update_by_systems(conn, source, target, src, tgt, attrs)
else
{:error, :not_found} ->
Logger.error("[update_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)}")
{:error, :not_found}
{:error, reason} ->
Logger.error("[update_connection] Error: #{inspect(reason)}")
{:error, reason}
error ->
Logger.error("[update_connection] Unexpected error: #{inspect(error)}")
{:error, :internal_server_error}
end
end
defp do_update_by_systems(conn, source, target, src, tgt, attrs) do
map_id = conn.assigns.map_id
case Operations.get_connection_by_systems(map_id, source, target) do
{:ok, nil} ->
Logger.error("[update_connection] No connection found for source=#{inspect(source)}, target=#{inspect(target)}")
try_reverse_update(conn, source, target, src, tgt, attrs)
{:ok, conn_struct} ->
do_update_connection(conn, conn_struct.id, attrs)
{:error, _} ->
try_reverse_update(conn, source, target, src, tgt, attrs)
end
end
defp try_reverse_update(conn, source, target, src, tgt, attrs) do
map_id = conn.assigns.map_id
case Operations.get_connection_by_systems(map_id, target, source) do
{:ok, nil} ->
Logger.error("[update_connection] No connection found for source=#{inspect(target)}, target=#{inspect(source)}")
{:error, :not_found}
{:ok, conn_struct} ->
do_update_connection(conn, conn_struct.id, attrs)
{:error, reason} ->
Logger.error("[update_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)} (both orders)")
{:error, reason}
end
end
defp do_update_connection(conn, id, attrs) do
case Operations.update_connection(conn, id, attrs) do
{:ok, updated_conn} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn))
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
Logger.error("[update_connection] Ash update NotFound for id=#{id}")
{:error, :not_found}
err -> err
end
end
@deprecated "Use GET /api/maps/:map_identifier/systems instead"
operation :list_all_connections,
summary: "List All Connections (Legacy)",
deprecated: true,
parameters: [map_id: [in: :query]],
responses: ResponseSchemas.standard_responses(@list_response_schema)
def list_all_connections(%{assigns: %{map_id: map_id}} = conn, _params) do
connections = Operations.list_connections(map_id)
data = Enum.map(connections, &APIUtils.connection_to_json/1)
APIUtils.respond_data(conn, data)
end
end

View File

@@ -0,0 +1,477 @@
# lib/wanderer_app_web/controllers/map_system_api_controller.ex
defmodule WandererAppWeb.MapSystemAPIController do
@moduledoc """
API controller for managing map systems and their associated connections.
Provides CRUD operations and batch upsert for systems and connections.
"""
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias OpenApiSpex.Schema
alias WandererApp.Map.Operations
alias WandererAppWeb.Helpers.APIUtils
alias WandererAppWeb.Schemas.{ApiSchemas, ResponseSchemas}
action_fallback WandererAppWeb.FallbackController
# -- JSON Schemas --
@map_system_schema %Schema{
type: :object,
properties: %{
id: %Schema{type: :string, description: "Map system UUID"},
map_id: %Schema{type: :string, description: "Map UUID"},
solar_system_id: %Schema{type: :integer, description: "EVE solar system ID"},
solar_system_name: %Schema{type: :string, description: "EVE solar system name"},
region_name: %Schema{type: :string, description: "EVE region name"},
position_x: %Schema{type: :number, format: :float, description: "X coordinate"},
position_y: %Schema{type: :number, format: :float, description: "Y coordinate"},
status: %Schema{type: :string, description: "System status"},
visible: %Schema{type: :boolean, description: "Visibility flag"},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag"},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :array, items: %Schema{type: :string}, nullable: true, description: "Labels"}
},
required: ~w(id map_id solar_system_id)a
}
@system_request_schema %Schema{
type: :object,
properties: %{
solar_system_id: %Schema{type: :integer, description: "EVE solar system ID"},
solar_system_name: %Schema{type: :string, description: "EVE solar system name"},
position_x: %Schema{type: :number, format: :float, description: "X coordinate"},
position_y: %Schema{type: :number, format: :float, description: "Y coordinate"},
status: %Schema{type: :string, description: "System status"},
visible: %Schema{type: :boolean, description: "Visibility flag"},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag"},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :array, items: %Schema{type: :string}, nullable: true, description: "Labels"}
},
required: ~w(solar_system_id)a,
example: %{
solar_system_id: 30_000_142,
solar_system_name: "Jita",
position_x: 100.5,
position_y: 200.3,
visible: true
}
}
@system_update_schema %Schema{
type: :object,
properties: %{
solar_system_name: %Schema{type: :string, description: "EVE solar system name", nullable: true},
position_x: %Schema{type: :number, format: :float, description: "X coordinate", nullable: true},
position_y: %Schema{type: :number, format: :float, description: "Y coordinate", nullable: true},
status: %Schema{type: :string, description: "System status", nullable: true},
visible: %Schema{type: :boolean, description: "Visibility flag", nullable: true},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag", nullable: true},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :array, items: %Schema{type: :string}, nullable: true, description: "Labels"}
},
example: %{
solar_system_name: "Jita",
position_x: 101.0,
position_y: 202.0,
visible: false,
status: "active",
tag: "HQ",
locked: true
}
}
@map_connection_schema %Schema{
type: :object,
properties: %{
id: %Schema{type: :string, description: "Connection UUID"},
map_id: %Schema{type: :string, description: "Map UUID"},
solar_system_source: %Schema{type: :integer},
solar_system_target: %Schema{type: :integer},
type: %Schema{type: :integer},
mass_status: %Schema{type: :integer, nullable: true},
time_status: %Schema{type: :integer, nullable: true},
ship_size_type: %Schema{type: :integer, nullable: true},
locked: %Schema{type: :boolean},
custom_info: %Schema{type: :string, nullable: true},
wormhole_type: %Schema{type: :string, nullable: true}
},
required: ~w(id map_id solar_system_source solar_system_target)a
}
@list_response_schema %Schema{
type: :object,
properties: %{
data: %Schema{
type: :object,
properties: %{
systems: %Schema{type: :array, items: @map_system_schema},
connections: %Schema{type: :array, items: @map_connection_schema}
}
}
},
example: %{
data: %{
systems: [
%{
id: "sys-uuid-1",
map_id: "map-uuid-1",
solar_system_id: 30_000_142,
solar_system_name: "Jita",
region_name: "The Forge",
position_x: 100.5,
position_y: 200.3,
status: "active",
visible: true,
description: "Trade hub",
tag: "HQ",
locked: false,
temporary_name: nil,
labels: ["market", "hub"]
}
],
connections: [
%{
id: "conn-uuid-1",
map_id: "map-uuid-1",
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0,
mass_status: 1,
time_status: 2,
ship_size_type: 1,
locked: false,
custom_info: "Frigate only",
wormhole_type: "C2"
}
]
}
}
}
@detail_response_schema %Schema{
type: :object,
properties: %{
data: @map_system_schema
},
example: %{
data: %{
id: "sys-uuid-1",
map_id: "map-uuid-1",
solar_system_id: 30_000_142,
solar_system_name: "Jita",
region_name: "The Forge",
position_x: 100.5,
position_y: 200.3,
status: "active",
visible: true,
description: "Trade hub",
tag: "HQ",
locked: false,
temporary_name: nil,
labels: ["market", "hub"]
}
}
}
@delete_response_schema %Schema{
type: :object,
properties: %{deleted: %Schema{type: :boolean, description: "Deleted flag"}},
required: ["deleted"],
example: %{deleted: true}
}
@batch_response_schema %Schema{
type: :object,
properties: %{
data: %Schema{
type: :object,
properties: %{
systems: %Schema{
type: :object,
properties: %{created: %Schema{type: :integer}, updated: %Schema{type: :integer}},
required: ~w(created updated)a
},
connections: %Schema{
type: :object,
properties: %{created: %Schema{type: :integer}, updated: %Schema{type: :integer}, deleted: %Schema{type: :integer}},
required: ~w(created updated deleted)a
}
},
required: ~w(systems connections)a
}
},
example: %{
data: %{
systems: %{created: 2, updated: 1},
connections: %{created: 1, updated: 0, deleted: 1}
}
}
}
@batch_delete_schema %Schema{
type: :object,
properties: %{
system_ids: %Schema{
type: :array,
items: %Schema{type: :integer},
description: "IDs to delete"
},
connection_ids: %Schema{
type: :array,
items: %Schema{type: :string},
description: "Connection UUIDs to delete",
nullable: true
}
},
required: ["system_ids"],
example: %{
system_ids: [30_000_142, 30_000_143],
connection_ids: ["conn-uuid-1", "conn-uuid-2"]
}
}
@batch_delete_response_schema %Schema{
type: :object,
properties: %{deleted_count: %Schema{type: :integer, description: "Deleted count"}},
required: ["deleted_count"],
example: %{deleted_count: 2}
}
@batch_request_schema ApiSchemas.data_wrapper(%Schema{
type: :object,
properties: %{
systems: %Schema{type: :array, items: @system_request_schema},
connections: %Schema{type: :array, items: %Schema{
type: :object,
properties: %{
solar_system_source: %Schema{type: :integer, description: "Source system ID"},
solar_system_target: %Schema{type: :integer, description: "Target system ID"},
type: %Schema{type: :integer, description: "Connection type (default 0)"},
mass_status: %Schema{type: :integer, description: "Mass status (0-3)", nullable: true},
time_status: %Schema{type: :integer, description: "Time decay status (0-3)", nullable: true},
ship_size_type: %Schema{type: :integer, description: "Ship size limit (0-3)", nullable: true},
locked: %Schema{type: :boolean, description: "Lock flag", nullable: true},
custom_info: %Schema{type: :string, description: "Optional metadata", nullable: true}
},
required: ~w(solar_system_source solar_system_target)a
}}
},
example: %{
systems: [
%{
solar_system_id: 30_000_142,
solar_system_name: "Jita",
position_x: 100.5,
position_y: 200.3,
visible: true
}
],
connections: [
%{
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0
}
]
}
})
# -- Actions --
operation :index,
summary: "List Map Systems and Connections",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "my-map-slug or map UUID"
]
],
responses: [
ok: {
"List Map Systems and Connections",
"application/json",
@list_response_schema
}
]
def index(%{assigns: %{map_id: map_id}} = conn, _params) do
systems = Operations.list_systems(map_id) |> Enum.map(&APIUtils.map_system_to_json/1)
connections = Operations.list_connections(map_id) |> Enum.map(&APIUtils.connection_to_json/1)
APIUtils.respond_data(conn, %{systems: systems, connections: connections})
end
operation :show,
summary: "Show Map System",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "my-map-slug or map UUID"
],
id: [in: :path, type: :string, required: true]
],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
with {:ok, system_id} <- APIUtils.parse_int(id),
{:ok, system} <- Operations.get_system(map_id, system_id) do
APIUtils.respond_data(conn, APIUtils.map_system_to_json(system))
end
end
operation :create,
summary: "Upsert Systems and Connections (batch or single)",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "my-map-slug or map UUID"
]
],
request_body: {"Systems+Connections upsert", "application/json", @batch_request_schema},
responses: ResponseSchemas.standard_responses(@batch_response_schema)
def create(conn, params) do
systems = Map.get(params, "systems", [])
connections = Map.get(params, "connections", [])
case Operations.upsert_systems_and_connections(conn, systems, connections) do
{:ok, result} ->
APIUtils.respond_data(conn, result)
error ->
error
end
end
operation :update,
summary: "Update System",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "my-map-slug or map UUID"
],
id: [in: :path, type: :string, required: true]
],
request_body: {"System update request", "application/json", @system_update_schema},
responses: ResponseSchemas.update_responses(@detail_response_schema)
def update(conn, %{"id" => id} = params) do
with {:ok, sid} <- APIUtils.parse_int(id),
{:ok, attrs} <- APIUtils.extract_update_params(params),
update_attrs = Map.put(attrs, "solar_system_id", sid),
{:ok, system} <- Operations.update_system(conn, sid, update_attrs) do
APIUtils.respond_data(conn, APIUtils.map_system_to_json(system))
end
end
operation :delete,
summary: "Batch Delete Systems and Connections",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "my-map-slug or map UUID"
]
],
request_body: {"Batch delete", "application/json", @batch_delete_schema},
responses: ResponseSchemas.standard_responses(@batch_delete_response_schema)
def delete(conn, params) do
system_ids = Map.get(params, "system_ids", [])
connection_ids = Map.get(params, "connection_ids", [])
deleted_systems = Enum.map(system_ids, &delete_system_id(conn, &1))
deleted_connections = Enum.map(connection_ids, &delete_connection_id(conn, &1))
systems_deleted = Enum.count(deleted_systems, &match?({:ok, _}, &1))
connections_deleted = Enum.count(deleted_connections, &match?({:ok, _}, &1))
deleted_count = systems_deleted + connections_deleted
APIUtils.respond_data(conn, %{deleted_count: deleted_count})
end
defp delete_system_id(conn, id) do
case APIUtils.parse_int(id) do
{:ok, sid} -> Operations.delete_system(conn, sid)
_ -> {:error, :invalid_id}
end
end
defp delete_connection_id(conn, id) do
case Operations.get_connection(conn, id) do
{:ok, conn_struct} ->
source_id = conn_struct.solar_system_source
target_id = conn_struct.solar_system_target
case Operations.delete_connection(conn, source_id, target_id) do
:ok -> {:ok, conn_struct}
error -> error
end
_ -> {:error, :invalid_id}
end
end
operation :delete_single,
summary: "Delete a single Map System",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "my-map-slug or map UUID"
],
id: [in: :path, type: :string, required: true]
],
responses: ResponseSchemas.standard_responses(@delete_response_schema)
def delete_single(conn, %{"id" => id}) do
with {:ok, sid} <- APIUtils.parse_int(id),
{:ok, _} <- Operations.delete_system(conn, sid) do
APIUtils.respond_data(conn, %{deleted: true})
else
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> APIUtils.respond_data(%{deleted: false, error: "System not found"})
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> APIUtils.respond_data(%{deleted: false, error: "Failed to delete system", reason: reason})
_ ->
conn
|> put_status(:bad_request)
|> APIUtils.respond_data(%{deleted: false, error: "Invalid system ID format"})
end
end
# -- Legacy endpoints --
operation :list_systems,
summary: "List Map Systems (Legacy)",
deprecated: true,
description: "Deprecated, use GET /api/maps/:map_identifier/systems instead",
parameters: [map_id: [in: :query]],
responses: ResponseSchemas.standard_responses(@list_response_schema)
defdelegate list_systems(conn, params), to: __MODULE__, as: :index
operation :show_system,
summary: "Show Map System (Legacy)",
deprecated: true,
description: "Deprecated, use GET /api/maps/:map_identifier/systems/:id instead",
parameters: [map_id: [in: :query], id: [in: :query]],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
defdelegate show_system(conn, params), to: __MODULE__, as: :show
end

View File

@@ -0,0 +1,169 @@
defmodule WandererAppWeb.MapSystemSignatureAPIController do
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias WandererApp.Api.MapSystemSignature
alias WandererApp.Map.Operations, as: MapOperations
@moduledoc """
API controller for managing map system signatures.
"""
# Inlined OpenAPI schema for a map system signature
@signature_schema %OpenApiSpex.Schema{
title: "MapSystemSignature",
type: :object,
properties: %{
id: %OpenApiSpex.Schema{type: :string, format: :uuid},
system_id: %OpenApiSpex.Schema{type: :string, format: :uuid},
eve_id: %OpenApiSpex.Schema{type: :string},
character_eve_id: %OpenApiSpex.Schema{type: :string},
name: %OpenApiSpex.Schema{type: :string, nullable: true},
description: %OpenApiSpex.Schema{type: :string, nullable: true},
type: %OpenApiSpex.Schema{type: :string, nullable: true},
linked_system_id: %OpenApiSpex.Schema{type: :integer, nullable: true},
kind: %OpenApiSpex.Schema{type: :string, nullable: true},
group: %OpenApiSpex.Schema{type: :string, nullable: true},
custom_info: %OpenApiSpex.Schema{type: :string, nullable: true},
updated: %OpenApiSpex.Schema{type: :integer, nullable: true},
inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time},
updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time}
},
required: [
:id, :system_id, :eve_id, :character_eve_id
],
example: %{
id: "sig-uuid-1",
system_id: "sys-uuid-1",
eve_id: "ABC-123",
character_eve_id: "123456789",
name: "Wormhole K162",
description: "Leads to unknown space",
type: "Wormhole",
linked_system_id: 30000144,
kind: "cosmic_signature",
group: "wormhole",
custom_info: "Fresh",
updated: 1,
inserted_at: "2025-04-30T10:00:00Z",
updated_at: "2025-04-30T10:00:00Z"
}
}
@doc """
List all signatures for a map.
"""
operation :index,
summary: "List all signatures for a map",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true]
],
responses: [ok: {"List of signatures", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
data: %OpenApiSpex.Schema{
type: :array,
items: @signature_schema
}
},
example: %{
data: [@signature_schema.example]
}
}}]
def index(conn, _params) do
map_id = conn.assigns.map_id
signatures = MapOperations.list_signatures(map_id)
json(conn, %{data: signatures})
end
@doc """
Show a single signature by ID.
"""
operation :show,
summary: "Show a single signature by ID",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true],
id: [in: :path, description: "Signature UUID", type: :string, required: true]
],
responses: [ok: {"Signature", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{data: @signature_schema},
example: %{data: @signature_schema.example}
}}]
def show(conn, %{"id" => id}) do
map_id = conn.assigns.map_id
case MapSystemSignature.by_id(id) do
{:ok, signature} ->
case WandererApp.Api.MapSystem.by_id(signature.system_id) do
{:ok, system} when system.map_id == map_id ->
json(conn, %{data: signature})
_ ->
conn |> put_status(:not_found) |> json(%{error: "Signature not found"})
end
_ -> conn |> put_status(:not_found) |> json(%{error: "Signature not found"})
end
end
@doc """
Create a new signature.
"""
operation :create,
summary: "Create a new signature",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true]
],
request_body: {"Signature", "application/json", @signature_schema},
responses: [created: {"Created signature", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{data: @signature_schema},
example: %{data: @signature_schema.example}
}}]
def create(conn, params) do
case MapOperations.create_signature(conn, params) do
{:ok, sig} -> conn |> put_status(:created) |> json(%{data: sig})
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
Update a signature by ID.
"""
operation :update,
summary: "Update a signature by ID",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true],
id: [in: :path, description: "Signature UUID", type: :string, required: true]
],
request_body: {"Signature update", "application/json", @signature_schema},
responses: [ok: {"Updated signature", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{data: @signature_schema},
example: %{data: @signature_schema.example}
}}]
def update(conn, %{"id" => id} = params) do
case MapOperations.update_signature(conn, id, params) do
{:ok, sig} -> json(conn, %{data: sig})
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
Delete a signature by ID.
"""
operation :delete,
summary: "Delete a signature by ID",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true],
id: [in: :path, description: "Signature UUID", type: :string, required: true]
],
responses: [no_content: {"Deleted", "application/json", %OpenApiSpex.Schema{
type: :object,
example: %{}
}}]
def delete(conn, %{"id" => id}) do
case MapOperations.delete_signature(conn, id) do
:ok -> send_resp(conn, :no_content, "")
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
end

View File

@@ -0,0 +1,192 @@
defmodule WandererAppWeb.MapSystemStructureAPIController do
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias WandererApp.Api.MapSystemStructure
alias OpenApiSpex.Schema
alias WandererApp.Map.Operations, as: MapOperations
@moduledoc """
API controller for managing map system structures.
Includes legacy structure-timers endpoint (deprecated).
"""
# Inlined OpenAPI schema for a map system structure
@structure_schema %Schema{
title: "MapSystemStructure",
type: :object,
properties: %{
id: %Schema{type: :string, format: :uuid},
system_id: %Schema{type: :string, format: :uuid},
solar_system_name: %Schema{type: :string},
solar_system_id: %Schema{type: :integer},
structure_type_id: %Schema{type: :string},
structure_type: %Schema{type: :string},
character_eve_id: %Schema{type: :string},
name: %Schema{type: :string},
notes: %Schema{type: :string, nullable: true},
owner_name: %Schema{type: :string, nullable: true},
owner_ticker: %Schema{type: :string, nullable: true},
owner_id: %Schema{type: :string, nullable: true},
status: %Schema{type: :string, nullable: true},
end_time: %Schema{type: :string, format: :date_time, nullable: true},
inserted_at: %Schema{type: :string, format: :date_time},
updated_at: %Schema{type: :string, format: :date_time}
},
required: [
:id, :system_id, :solar_system_name, :solar_system_id, :structure_type_id, :structure_type, :character_eve_id, :name
],
example: %{
id: "struct-uuid-1",
system_id: "sys-uuid-1",
solar_system_name: "Jita",
solar_system_id: 30000142,
structure_type_id: "35832",
structure_type: "Astrahus",
character_eve_id: "123456789",
name: "Jita Trade Hub",
notes: "Main market structure",
owner_name: "Wanderer Corp",
owner_ticker: "WANDR",
owner_id: "corp-uuid-1",
status: "anchoring",
end_time: "2025-05-01T12:00:00Z",
inserted_at: "2025-04-30T10:00:00Z",
updated_at: "2025-04-30T10:00:00Z"
}
}
@doc """
List all structures for a map.
"""
operation :index,
summary: "List all structures for a map",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true]
],
responses: [ok: {"List of structures", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{
data: %OpenApiSpex.Schema{
type: :array,
items: @structure_schema
}
},
example: %{
data: [@structure_schema.example]
}
}}]
def index(conn, _params) do
map_id = conn.assigns.map_id
structures = MapOperations.list_structures(map_id)
json(conn, %{data: structures})
end
@doc """
Show a single structure by ID.
"""
operation :show,
summary: "Show a single structure by ID",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true],
id: [in: :path, description: "Structure UUID", type: :string, required: true]
],
responses: [ok: {"Structure", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{data: @structure_schema},
example: %{data: @structure_schema.example}
}}]
def show(conn, %{"id" => id}) do
map_id = conn.assigns.map_id
case MapSystemStructure.by_id(id) do
{:ok, structure} ->
case WandererApp.Api.MapSystem.by_id(structure.system_id) do
{:ok, system} when system.map_id == map_id ->
json(conn, %{data: structure})
_ ->
conn |> put_status(:not_found) |> json(%{error: "Structure not found"})
end
_ -> conn |> put_status(:not_found) |> json(%{error: "Structure not found"})
end
end
@doc """
Create a new structure.
"""
operation :create,
summary: "Create a new structure",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true]
],
request_body: {"Structure", "application/json", @structure_schema},
responses: [created: {"Created structure", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{data: @structure_schema},
example: %{data: @structure_schema.example}
}}]
def create(conn, params) do
case MapOperations.create_structure(conn, params) do
{:ok, struct} -> conn |> put_status(:created) |> json(%{data: struct})
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
Update a structure by ID.
"""
operation :update,
summary: "Update a structure by ID",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true],
id: [in: :path, description: "Structure UUID", type: :string, required: true]
],
request_body: {"Structure update", "application/json", @structure_schema},
responses: [ok: {"Updated structure", "application/json", %OpenApiSpex.Schema{
type: :object,
properties: %{data: @structure_schema},
example: %{data: @structure_schema.example}
}}]
def update(conn, %{"id" => id} = params) do
case MapOperations.update_structure(conn, id, params) do
{:ok, struct} -> json(conn, %{data: struct})
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
Delete a structure by ID.
"""
operation :delete,
summary: "Delete a structure by ID",
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true],
id: [in: :path, description: "Structure UUID", type: :string, required: true]
],
responses: [no_content: {"Deleted", "application/json", %OpenApiSpex.Schema{
type: :object,
example: %{}
}}]
def delete(conn, %{"id" => id}) do
case MapOperations.delete_structure(conn, id) do
:ok -> send_resp(conn, :no_content, "")
{:error, error} -> conn |> put_status(:unprocessable_entity) |> json(%{error: error})
end
end
@doc """
@deprecated "Use /structures instead. This endpoint will be removed in a future release."
Legacy: Get structure timers for a map.
"""
operation :structure_timers,
summary: "Get structure timers for a map (Legacy)",
deprecated: true,
parameters: [
map_identifier: [in: :path, description: "Map identifier (UUID or slug)", type: :string, required: true]
],
responses: [ok: {"Structure timers", "application/json", %Schema{type: :array, items: %Schema{type: :object}}}]
def structure_timers(conn, _params) do
map_id = conn.assigns.map_id
structures = MapOperations.list_structures(map_id)
json(conn, %{data: structures})
end
end

View File

@@ -0,0 +1,21 @@
defmodule WandererAppWeb.Plugs.AssignMapOwner do
import Plug.Conn
alias WandererApp.Map.Operations
def init(opts), do: opts
def call(conn, _opts) do
map_id = conn.assigns[:map_id]
case Operations.get_owner_character_id(map_id) do
{:ok, %{id: char_id, user_id: user_id}} ->
conn
|> assign(:owner_character_id, char_id)
|> assign(:owner_user_id, user_id)
_ ->
conn
|> assign(:owner_character_id, nil)
|> assign(:owner_user_id, nil)
end
end
end

View File

@@ -1,52 +1,116 @@
defmodule WandererAppWeb.Plugs.CheckMapApiKey do
@moduledoc """
A plug that checks the "Authorization: Bearer <token>" header
against the map's stored public_api_key. Halts with 401 if invalid.
"""
@behaviour Plug
import Plug.Conn
alias WandererAppWeb.UtilAPIController, as: Util
alias Plug.Crypto
alias WandererApp.Api.Map, as: ApiMap
alias WandererAppWeb.Schemas.ResponseSchemas, as: R
require Logger
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
header = get_req_header(conn, "authorization") |> List.first()
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, map_id} <- fetch_map_id(conn),
{:ok, map} <- ApiMap.by_id(map_id),
true <- is_binary(map.public_api_key) &&
Crypto.secure_compare(map.public_api_key, token)
do
conn
|> assign(:map, map)
|> assign(:map_id, map.id)
else
[] ->
Logger.warning("Missing or invalid 'Bearer' token")
conn |> respond(401, "Missing or invalid 'Bearer' token") |> halt()
case header do
"Bearer " <> incoming_token ->
case fetch_map(conn.query_params) do
{:ok, map} ->
if map.public_api_key == incoming_token do
conn
else
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "Unauthorized (invalid token for map)"}))
|> halt()
end
{:error, :bad_request, msg} ->
Logger.warning("Bad request: #{msg}")
conn |> respond(400, msg) |> halt()
{:error, _reason} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(404, Jason.encode!(%{error: "Map not found"}))
|> halt()
end
{:error, :not_found, msg} ->
Logger.warning("Not found: #{msg}")
conn |> respond(404, msg) |> halt()
_ ->
{:error, _} ->
Logger.warning("Map identifier required")
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "Missing or invalid 'Bearer' token"}))
|> respond(400, "Map identifier required. Provide `map_identifier` in the path or `map_id`/`slug` in query.")
|> halt()
end
end
defp fetch_map(query_params) do
case Util.fetch_map_id(query_params) do
{:ok, map_id} ->
WandererApp.Api.Map.by_id(map_id)
false ->
Logger.warning("Unauthorized: invalid token for map #{inspect(conn.params["map_identifier"])}")
conn |> respond(401, "Unauthorized (invalid token for map)") |> halt()
error ->
error
Logger.error("Unexpected error: #{inspect(error)}")
conn |> respond(500, "Unexpected error") |> halt()
end
end
# Try unified path param first, then fall back to legacy query params
defp fetch_map_id(%Plug.Conn{params: %{"map_identifier" => id}}) when is_binary(id) and id != "" do
resolve_identifier(id)
end
defp fetch_map_id(conn), do: legacy_fetch(conn)
# Try ID lookup first, then slug lookup
defp resolve_identifier(id) do
case ApiMap.by_id(id) do
{:ok, %{id: map_id}} ->
{:ok, map_id}
_ ->
case ApiMap.get_map_by_slug(id) do
{:ok, %{id: map_id}} ->
{:ok, map_id}
_ ->
{:error, :not_found, "Map not found for identifier: #{id}"}
end
end
end
# Legacy: check assigns, then params["map_id"], then params["slug"]
defp legacy_fetch(conn) do
map_id_from_assign = conn.assigns[:map_id]
map_id_param = conn.params["map_id"]
slug_param = conn.params["slug"]
cond do
is_binary(map_id_from_assign) and map_id_from_assign != "" ->
{:ok, map_id_from_assign}
is_binary(map_id_param) and map_id_param != "" ->
{:ok, map_id_param}
is_binary(slug_param) and slug_param != "" ->
case ApiMap.get_map_by_slug(slug_param) do
{:ok, %{id: map_id}} -> {:ok, map_id}
_ -> {:error, :not_found, "Map not found for slug: #{slug_param}"}
end
true ->
{:error, :bad_request,
"Map identifier required. Provide `map_identifier` in the path or `map_id`/`slug` in query."}
end
end
# Pick the right shared schema and send JSON
defp respond(conn, status, msg) do
{_desc, content_type, _schema} =
case status do
400 -> R.bad_request(msg)
401 -> R.unauthorized(msg)
404 -> R.not_found(msg)
500 -> R.internal_server_error(msg)
_ -> R.internal_server_error("Unexpected error")
end
conn
|> put_resp_content_type(content_type)
|> send_resp(status, Jason.encode!(%{error: msg}))
end
end

View File

@@ -5,11 +5,13 @@ defmodule WandererAppWeb.Plugs.CheckMapSubscription do
"""
import Plug.Conn
require Logger
def init(opts), do: opts
def call(conn, _opts) do
case fetch_map_id(conn.query_params) do
# First check if map_id is already in conn.assigns (from CheckMapApiKey)
case get_map_id_from_assigns_or_params(conn) do
{:ok, map_id} ->
{:ok, is_subscription_active} = map_id |> WandererApp.Map.is_subscription_active?()
@@ -28,6 +30,17 @@ defmodule WandererAppWeb.Plugs.CheckMapSubscription do
end
end
# First try to get map_id from conn.assigns
defp get_map_id_from_assigns_or_params(conn) do
if Map.has_key?(conn.assigns, :map_id) do
Logger.debug("Found map_id in conn.assigns: #{conn.assigns.map_id}")
{:ok, conn.assigns.map_id}
else
# Fall back to query params if not in assigns
fetch_map_id(conn.query_params)
end
end
defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
{:ok, mid}
end

View File

@@ -1,41 +0,0 @@
defmodule WandererAppWeb.UtilAPIController do
@moduledoc """
Utility functions for parameter handling, fetch helpers, etc.
"""
alias WandererApp.Api
def fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
{:ok, mid}
end
def fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do
case Api.Map.get_map_by_slug(slug) do
{:ok, map} ->
{:ok, map.id}
{:error, _reason} ->
{:error, "No map found for slug=#{slug}"}
end
end
def fetch_map_id(_),
do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
# Require a given param to be present and non-empty
def require_param(params, key) do
case params[key] do
nil -> {:error, "Missing required param: #{key}"}
"" -> {:error, "Param #{key} cannot be empty"}
val -> {:ok, val}
end
end
# Parse a string into an integer
def parse_int(str) do
case Integer.parse(str) do
{num, ""} -> {:ok, num}
_ -> {:error, "Invalid integer for param id=#{str}"}
end
end
end

View File

@@ -0,0 +1,301 @@
defmodule WandererAppWeb.Helpers.APIUtils do
@moduledoc """
Unified helper module for API operations:
- Parameter parsing and validation
- Map ID resolution
- Standardized responses
- JSON serialization
"""
# Explicit imports to avoid unnecessary dependencies
import Plug.Conn, only: [put_status: 2]
import Phoenix.Controller, only: [json: 2]
alias WandererApp.Api.Map, as: MapApi
alias WandererApp.Api.MapSolarSystem
require Logger
# -----------------------------------------------------------------------------
# Map ID Resolution
# -----------------------------------------------------------------------------
@spec fetch_map_id(map()) :: {:ok, String.t()} | {:error, String.t()}
def fetch_map_id(%{"map_id" => id}) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, _} -> {:ok, id}
:error -> {:error, "Invalid UUID format for map_id: #{id}"}
end
end
def fetch_map_id(%{"slug" => slug}) when is_binary(slug) do
case MapApi.get_map_by_slug(slug) do
{:ok, %{id: id}} -> {:ok, id}
_ -> {:error, "No map found for slug=#{slug}"}
end
end
def fetch_map_id(_), do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
# -----------------------------------------------------------------------------
# Parameter Validators and Parsers
# -----------------------------------------------------------------------------
@spec require_param(map(), String.t()) :: {:ok, any()} | {:error, String.t()}
def require_param(params, key) do
case Map.fetch(params, key) do
{:ok, val} when is_binary(val) ->
trimmed = String.trim(val)
if trimmed == "" do
{:error, "Param #{key} cannot be empty"}
else
{:ok, trimmed}
end
{:ok, val} ->
{:ok, val}
:error ->
{:error, "Missing required param: #{key}"}
end
end
@spec parse_int(binary() | integer()) :: {:ok, integer()} | {:error, String.t()}
def parse_int(str) when is_binary(str) do
Logger.debug("Parsing integer from: #{inspect(str)}")
case Integer.parse(str) do
{num, ""} -> {:ok, num}
_ -> {:error, "Invalid integer format: #{str}"}
end
end
def parse_int(num) when is_integer(num), do: {:ok, num}
def parse_int(other), do: {:error, "Expected integer or string, got: #{inspect(other)}"}
@spec parse_int!(binary() | integer()) :: integer()
def parse_int!(str) do
case parse_int(str) do
{:ok, num} -> num
{:error, msg} -> raise ArgumentError, msg
end
end
@spec validate_uuid(any()) :: {:ok, String.t()} | {:error, String.t()}
def validate_uuid(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, uuid} -> {:ok, uuid}
:error -> {:error, "Invalid UUID format: #{id}"}
end
end
def validate_uuid(_), do: {:error, "ID must be a UUID string"}
# -----------------------------------------------------------------------------
# Parameter Extraction
# -----------------------------------------------------------------------------
@doc """
Extract and validate parameters for upserting a system.
Returns {:ok, attrs} or {:error, error_message}.
"""
@spec extract_upsert_params(map()) :: {:ok, map()} | {:error, String.t()}
def extract_upsert_params(params) when is_map(params) do
required = ["solar_system_id"]
optional = [
"solar_system_name", "position_x", "position_y", "coordinates",
"status", "visible", "description", "tag",
"locked", "temporary_name", "labels"
]
case Map.fetch(params, "solar_system_id") do
:error -> {:error, "Missing solar_system_id in request body"}
{:ok, _} ->
params
|> Map.take(required ++ optional)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
|> then(&{:ok, &1})
end
end
@doc """
Extract and validate parameters for updating a system.
Returns {:ok, attrs} or {:error, error_message}.
"""
@spec extract_update_params(map()) :: {:ok, map()} | {:error, String.t()}
def extract_update_params(params) when is_map(params) do
allowed = [
"solar_system_name", "position_x", "position_y", "coordinates",
"status", "visible", "description", "tag",
"locked", "temporary_name", "labels"
]
attrs =
params
|> Map.take(allowed)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
{:ok, attrs}
end
@spec normalize_connection_params(map()) :: {:ok, map()} | {:error, String.t()}
def normalize_connection_params(params) do
# Convert all keys to strings for consistent access
string_params = for {k, v} <- params, into: %{} do
{to_string(k), v}
end
# Define parameter mappings for normalization
aliases = %{
"source" => "solar_system_source",
"source_id" => "solar_system_source",
"target" => "solar_system_target",
"target_id" => "solar_system_target"
}
# Normalize parameters using aliases
normalized_params = Enum.reduce(aliases, string_params, fn {alias_key, std_key}, acc ->
if Map.has_key?(acc, alias_key) && !Map.has_key?(acc, std_key) do
Map.put(acc, std_key, acc[alias_key])
else
acc
end
end)
# Handle required parameters
with {:ok, src} <- parse_to_int(normalized_params["solar_system_source"], "solar_system_source"),
{:ok, tgt} <- parse_to_int(normalized_params["solar_system_target"], "solar_system_target") do
# Handle optional parameters with sane defaults
type = normalized_params["type"] || 0
mass_status = normalized_params["mass_status"] || 0
time_status = normalized_params["time_status"] || 0
ship_size_type = normalized_params["ship_size_type"] || 0
# Coerce to boolean; accept "true"/"false", 1/0, etc.
locked =
case normalized_params["locked"] do
val when val in [true, "true", 1, "1"] -> true
val when val in [false, "false", 0, "0"] -> false
nil -> false
other -> other # keep unknowns for caller-side validation
end
custom_info = normalized_params["custom_info"]
wormhole_type = normalized_params["wormhole_type"]
# Build standardized attrs map
attrs = %{
"solar_system_source" => src,
"solar_system_target" => tgt,
"type" => parse_optional_int(type, 0),
"mass_status" => parse_optional_int(mass_status, 0),
"time_status" => parse_optional_int(time_status, 0),
"ship_size_type" => parse_optional_int(ship_size_type, 0)
}
# Add non-nil optional attributes
attrs = if is_nil(locked), do: attrs, else: Map.put(attrs, "locked", locked)
attrs = if is_nil(custom_info), do: attrs, else: Map.put(attrs, "custom_info", custom_info)
attrs = if is_nil(wormhole_type), do: attrs, else: Map.put(attrs, "wormhole_type", wormhole_type)
{:ok, attrs}
else
{:error, msg} -> {:error, msg}
end
end
# Helper to handle various input formats
defp parse_to_int(nil, field), do: {:error, "Missing #{field}"}
defp parse_to_int(val, _field) when is_integer(val), do: {:ok, val}
defp parse_to_int(val, field) when is_binary(val) do
case Integer.parse(val) do
{i, ""} -> {:ok, i}
:error -> {:error, "Invalid #{field}: #{val}"}
_ -> {:error, "Invalid #{field}: #{val}"}
end
end
defp parse_to_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"}
defp parse_optional_int(nil, default), do: default
defp parse_optional_int(i, _default) when is_integer(i), do: i
defp parse_optional_int(s, default) when is_binary(s) do
case Integer.parse(s) do
{i, _} -> i
:error -> default
end
end
# -----------------------------------------------------------------------------
# Standardized JSON Responses
# -----------------------------------------------------------------------------
@spec respond_data(Plug.Conn.t(), any(), atom() | integer()) :: Plug.Conn.t()
def respond_data(conn, data, status \\ :ok) do
conn
|> put_status(status)
|> json(%{data: data})
end
@spec error_response(Plug.Conn.t(), atom() | integer(), String.t(), map() | nil) :: Plug.Conn.t()
def error_response(conn, status, message, details \\ nil) do
body = if details, do: %{error: message, details: details}, else: %{error: message}
conn
|> put_status(status)
|> json(body)
end
@spec error_not_found(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
def error_not_found(conn, message), do: error_response(conn, :not_found, message)
@doc """
Formats error messages for consistent display.
"""
@spec format_error(any()) :: String.t()
def format_error(error) when is_binary(error), do: error
def format_error(error) when is_atom(error), do: Atom.to_string(error)
def format_error(error), do: inspect(error)
# -----------------------------------------------------------------------------
# JSON Serialization
# -----------------------------------------------------------------------------
@spec map_system_to_json(struct()) :: map()
def map_system_to_json(system) do
base =
Map.take(system, ~w(
id map_id solar_system_id custom_name temporary_name description tag labels
locked visible status position_x position_y inserted_at updated_at
)a)
original = get_original_name(system.solar_system_id)
name = pick_name(system)
base
|> Map.put(:original_name, original)
|> Map.put(:name, name)
end
defp get_original_name(id) do
case MapSolarSystem.by_solar_system_id(id) do
{:ok, sys} -> sys.solar_system_name
_ -> "System #{id}"
end
end
defp pick_name(%{temporary_name: t, custom_name: c, solar_system_id: id}) do
cond do
t not in [nil, ""] -> t
c not in [nil, ""] -> c
true -> get_original_name(id)
end
end
@spec connection_to_json(struct()) :: map()
def connection_to_json(conn) do
Map.take(conn, ~w(
id map_id solar_system_source solar_system_target mass_status
time_status ship_size_type type wormhole_type inserted_at updated_at
)a)
end
end

View File

@@ -17,24 +17,26 @@ defmodule WandererAppWeb.MapCharacters do
|> handle_info_or_assign(assigns)}
end
# attr(:groups, :any, required: true)
# attr(:character_settings, :any, required: true)
@impl true
def render(assigns) do
~H"""
<div id={@id}>
<ul :for={group <- @groups} class="space-y-4 border-t border-b border-gray-200 py-4">
<ul :for={group <- @groups} class="border-t border-b border-gray-200 py-0">
<li :for={character <- group.characters}>
<div class="flex items-center justify-between w-full space-x-2 p-1 hover:bg-gray-900">
<.character_entry character={character} character_settings={@character_settings} />
<.character_entry character={character} />
<button
:if={character.tracked}
phx-click="untrack"
phx-value-event-data={character.id}
class="btn btn-sm btn-icon"
class="btn btn-sm btn-icon py-1"
>
<.icon name="hero-eye-slash" class="h-5 w-5" /> Untrack
</button>
<span :if={not character.tracked} class="text-white rounded-full px-2">
Viewer
</span>
</div>
</li>
</ul>
@@ -43,31 +45,43 @@ defmodule WandererAppWeb.MapCharacters do
end
attr(:character, :any, required: true)
attr(:character_settings, :any, required: true)
defp character_entry(assigns) do
~H"""
<div class="flex items-center gap-3 text-sm w-[450px]">
<span
:if={is_tracked?(@character.id, @character_settings)}
class="text-green-500 rounded-full px-2 py-1"
>
Tracked
<div class="flex flex-col p-4 items-center gap-2 tooltip tooltip-top" data-tip="Active from">
<span class="text-green-500 rounded-full px-2 py-1 whitespace-nowrap">
<.local_time id={@character.id} at={@character.from} />
</span>
</div>
<div class="avatar">
<div class="rounded-md w-8 h-8">
<img src={member_icon_url(@character.eve_id)} alt={@character.name} />
</div>
</div>
<span class="whitespace-nowrap">{@character.name}</span>
<span :if={@character.alliance_ticker} class="whitespace-nowrap">
[{@character.alliance_ticker}]
</span>
<span :if={@character.corporation_ticker} class="whitespace-nowrap">
[{@character.corporation_ticker}]
</span>
<span :if={is_online?(@character.id)} class="text-green-500 rounded-full px-2 py-1">
Online
</span>
<span :if={not is_online?(@character.id)} class="text-red-500 rounded-full px-2 py-1">
Offline
</span>
<div class="avatar">
<div class="rounded-md w-8 h-8">
<img src={member_icon_url(@character.eve_id)} alt={@character.name} />
</div>
</div>
<span>{@character.name}</span>
<span :if={@character.alliance_ticker}>[{@character.alliance_ticker}]</span>
<span :if={@character.corporation_ticker}>[{@character.corporation_ticker}]</span>
<span :if={@character.tracked} class="text-green-500 rounded-full px-2 py-1">
Tracked
</span>
<span :if={not @character.tracked} class="text-red-500 rounded-full px-2 py-1 whitespace-nowrap">
Not Tracked
</span>
</div>
"""
end
@@ -79,12 +93,6 @@ defmodule WandererAppWeb.MapCharacters do
{:noreply, socket}
end
defp is_tracked?(character_id, character_settings) do
Enum.any?(character_settings, fn setting ->
setting.character_id == character_id && setting.tracked
end)
end
defp is_online?(character_id) do
{:ok, state} = WandererApp.Character.get_character_state(character_id)
state.is_online

View File

@@ -335,7 +335,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
defp handle_tracking_event({:track_characters, map_characters, track_character}, socket, map_id) do
:ok =
WandererApp.Character.TrackingUtils.track_characters(
WandererApp.Character.TrackingUtils.track(
map_characters,
map_id,
track_character,

View File

@@ -56,18 +56,12 @@ defmodule WandererAppWeb.MapCoreEventHandler do
case track_character do
false ->
:ok =
WandererApp.Character.TrackingUtils.untrack_characters(
map_characters,
map_id,
self()
)
:ok = WandererApp.Character.TrackingUtils.untrack(map_characters, map_id, self())
:ok = WandererApp.Character.TrackingUtils.remove_characters(map_characters, map_id)
_ ->
:ok =
WandererApp.Character.TrackingUtils.track_characters(
WandererApp.Character.TrackingUtils.track(
map_characters,
map_id,
true,
@@ -232,7 +226,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
def handle_ui_event(
_event,
_body,
%{assigns: %{has_tracked_characters?: false}} =
%{assigns: %{has_tracked_characters?: false, can_track?: true}} =
socket
) do
Process.send_after(self(), %{event: :show_tracking}, 10)
@@ -248,7 +242,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
def handle_ui_event(
event,
body,
%{assigns: %{main_character_id: main_character_id}} =
%{assigns: %{main_character_id: main_character_id, can_track?: true}} =
socket
)
when is_nil(main_character_id) do
@@ -266,7 +260,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
{:ok, map_server_started} = WandererApp.Cache.lookup("map_#{map_id}:started", false)
if map_server_started do
Process.send_after(self(), %{event: :map_server_started}, 10)
Process.send_after(self(), %{event: :map_server_started}, 50)
else
WandererApp.Map.Manager.start_map(map_id)
end
@@ -439,6 +433,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
assigns: %{
current_user: current_user,
map_id: map_id,
main_character_id: main_character_id,
tracked_characters: tracked_characters,
has_tracked_characters?: has_tracked_characters?,
user_permissions:
@@ -460,7 +455,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do
end
events =
case not has_tracked_characters? do
case track_character && not has_tracked_characters? do
true ->
events ++ [:empty_tracked_characters]
@@ -468,13 +463,28 @@ defmodule WandererAppWeb.MapCoreEventHandler do
events
end
character_limit_reached? = present_character_ids |> Enum.count() >= characters_limit
events =
case present_character_ids |> Enum.count() < characters_limit do
true ->
cond do
# in case user has not tracked any character track his main character as viewer
track_character && not has_tracked_characters? ->
main_character = Enum.find(current_user.characters, &(&1.id == main_character_id))
events ++ [{:track_characters, [main_character], false}]
track_character && not character_limit_reached? ->
events ++ [{:track_characters, tracked_characters, track_character}]
_ ->
track_character && character_limit_reached? ->
events ++ [:map_character_limit]
# in case user has view only permissions track his main character as viewer
not track_character ->
main_character = Enum.find(current_user.characters, &(&1.id == main_character_id))
events ++ [{:track_characters, [main_character], track_character}]
true ->
events
end
initial_data =

View File

@@ -6,7 +6,7 @@ defmodule WandererAppWeb.MapStructuresEventHandler do
alias WandererApp.Api.MapSystem
alias WandererApp.Structure
alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler}
alias WandererAppWeb. MapCoreEventHandler
def handle_server_event(%{event: :structures_updated, payload: _solar_system_id}, socket) do
socket

View File

@@ -5,6 +5,8 @@ defmodule WandererAppWeb.MapCharactersLive do
alias WandererAppWeb.MapCharacters
@refresh_interval :timer.seconds(30)
def mount(
%{"slug" => map_slug} = _params,
_session,
@@ -44,6 +46,15 @@ defmodule WandererAppWeb.MapCharactersLive do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_info(
:refresh_tracking_data,
socket
) do
Process.send_after(self(), :refresh_tracking_data, @refresh_interval)
{:noreply, socket |> load_characters()}
end
@impl true
def handle_info(
_event,
@@ -101,17 +112,35 @@ defmodule WandererAppWeb.MapCharactersLive do
end
defp apply_action(socket, :index, _params) do
Process.send_after(self(), :refresh_tracking_data, @refresh_interval)
socket
|> assign(:active_page, :map_characters)
|> assign(:page_title, "Map - Characters")
|> load_characters()
end
defp get_all_characters(map_id) do
{:ok, present_characters} =
WandererApp.Cache.lookup(
"map_#{map_id}:presence_data",
[]
)
present_characters =
present_characters
|> Enum.map(fn character ->
character |> Map.merge(WandererApp.Character.get_character!(character.character_id))
end)
present_characters
end
defp load_characters(%{assigns: %{map_id: map_id}} = socket) do
map_characters =
map_id
|> WandererApp.Map.list_characters()
|> Enum.map(&map_ui_character/1)
|> get_all_characters()
|> Enum.map(fn character -> map_ui_character(map_id, character) end)
groups =
map_characters
@@ -132,20 +161,22 @@ defmodule WandererAppWeb.MapCharactersLive do
|> assign(:groups, groups)
end
defp map_ui_character(character),
do:
character
|> Map.take([
:id,
:user_id,
:eve_id,
:name,
:online,
:corporation_id,
:corporation_name,
:corporation_ticker,
:alliance_id,
:alliance_name,
:alliance_ticker
])
defp map_ui_character(map_id, character) do
character
|> Map.take([
:id,
:user_id,
:eve_id,
:name,
:online,
:corporation_id,
:corporation_name,
:corporation_ticker,
:alliance_id,
:alliance_name,
:alliance_ticker,
:from,
:tracked
])
end
end

View File

@@ -3,7 +3,7 @@
<.link navigate={~p"/#{@map_slug}"} class="text-neutral-100">
<%= @map_name %>
</.link>
- Characters [<%= @characters_count %>]
- Active Characters [<%= @characters_count %>]
</span>
</nav>
<main
@@ -24,7 +24,6 @@
id="map-characters"
notify_to={self()}
groups={@groups}
character_settings={@character_settings}
event_name="character_event"
/>
</div>

View File

@@ -291,6 +291,8 @@ defmodule WandererAppWeb.MapEventHandler do
def push_map_event(socket, _type, _body), do: socket
def map_ui_character_stat(nil), do: nil
def map_ui_character_stat(character),
do:
character

View File

@@ -10,19 +10,39 @@ defmodule WandererAppWeb.Presence do
end
def handle_metas(map_id, %{joins: _joins, leaves: _leaves}, presences, state) do
presence_character_ids =
presence_data =
presences
|> Enum.map(fn {character_id, _} -> character_id end)
|> Enum.map(fn {character_id, meta} ->
from =
meta
|> Enum.map(& &1.from)
|> Enum.sort(&(DateTime.compare(&1, &2) != :gt))
|> List.first()
any_tracked = Enum.any?(meta, fn %{tracked: tracked} -> tracked end)
%{character_id: character_id, tracked: any_tracked, from: from}
end)
presence_tracked_character_ids =
presence_data
|> Enum.filter(fn %{tracked: tracked} -> tracked end)
|> Enum.map(fn %{character_id: character_id} ->
character_id
end)
WandererApp.Cache.insert("map_#{map_id}:presence_updated", true)
WandererApp.Cache.insert("map_#{map_id}:presence_character_ids", presence_character_ids)
WandererApp.Cache.insert(
"map_#{map_id}:presence_character_ids",
presence_tracked_character_ids
)
WandererApp.Cache.insert(
"map_#{map_id}:presence_data",
presence_data
)
{:ok, state}
end
def presence_character_ids(map_id) do
map_id
|> list()
|> Enum.map(fn {character_id, _} -> character_id end)
end
end

View File

@@ -169,6 +169,7 @@ defmodule WandererAppWeb.Router do
pipeline :api_map do
plug WandererAppWeb.Plugs.CheckMapApiKey
plug WandererAppWeb.Plugs.CheckMapSubscription
plug WandererAppWeb.Plugs.AssignMapOwner
end
pipeline :api_kills do
@@ -206,17 +207,42 @@ defmodule WandererAppWeb.Router do
scope "/api/map", WandererAppWeb do
pipe_through [:api, :api_map]
get "/audit", MapAuditAPIController, :index
get "/systems", MapAPIController, :list_systems
get "/system", MapAPIController, :show_system
get "/connections", MapAPIController, :list_connections
get "/characters", MapAPIController, :tracked_characters_with_info
get "/structure-timers", MapAPIController, :show_structure_timers
# Deprecated routes - use /api/maps/:map_identifier/systems instead
get "/systems", MapSystemAPIController, :list_systems
get "/system", MapSystemAPIController, :show_system
get "/connections", MapConnectionAPIController, :list_all_connections
get "/characters", MapAPIController, :list_tracked_characters
get "/structure-timers", MapSystemStructureAPIController, :structure_timers
get "/character-activity", MapAPIController, :character_activity
get "/user_characters", MapAPIController, :user_characters
get "/acls", MapAccessListAPIController, :index
post "/acls", MapAccessListAPIController, :create
end
#
# Unified RESTful routes for systems & connections by slug or ID
#
scope "/api/maps/:map_identifier", WandererAppWeb do
pipe_through [:api, :api_map]
patch "/connections", MapConnectionAPIController, :update
delete "/connections", MapConnectionAPIController, :delete
delete "/systems", MapSystemAPIController, :delete
resources "/systems", MapSystemAPIController, only: [:index, :show, :create, :update, :delete]
resources "/connections", MapConnectionAPIController, only: [:index, :show, :create, :update, :delete], param: "id"
resources "/structures", MapSystemStructureAPIController, except: [:new, :edit]
get "/structure-timers", MapSystemStructureAPIController, :structure_timers
resources "/signatures", MapSystemSignatureAPIController, except: [:new, :edit]
get "/user-characters", MapAPIController, :show_user_characters
get "/tracked-characters", MapAPIController, :show_tracked_characters
end
#
# Other API routes
#
scope "/api/characters", WandererAppWeb do
pipe_through [:api, :api_character]
get "/", CharactersAPIController, :index

View File

@@ -0,0 +1,156 @@
defmodule WandererAppWeb.Schemas.ApiSchemas do
@moduledoc """
Shared OpenAPI schema definitions for the Wanderer API.
This module defines common schema components that can be reused
across different controller specifications.
"""
alias OpenApiSpex.Schema
# Standard response wrappers
def data_wrapper(schema) do
%Schema{
type: :object,
properties: %{
data: schema
},
required: ["data"]
}
end
# Standard error responses
def error_response(description \\ "Error") do
%Schema{
type: :object,
properties: %{
error: %Schema{type: :string, description: "Brief error message"},
details: %Schema{type: :string, description: "Detailed explanation", nullable: true},
code: %Schema{type: :string, description: "Optional error code", nullable: true}
},
required: ["error"],
example: %{"error" => description, "details" => "Additional information about the error"}
}
end
# Common entity schemas
def character_schema do
%Schema{
type: :object,
properties: %{
eve_id: %Schema{type: :string},
name: %Schema{type: :string},
corporation_id: %Schema{type: :string},
corporation_ticker: %Schema{type: :string},
alliance_id: %Schema{type: :string},
alliance_ticker: %Schema{type: :string}
},
required: ["eve_id", "name"]
}
end
# Common system schema based on what we've seen in controllers
def solar_system_basic_schema do
%Schema{
type: :object,
properties: %{
solar_system_id: %Schema{type: :integer},
solar_system_name: %Schema{type: :string},
region_id: %Schema{type: :integer},
region_name: %Schema{type: :string},
constellation_id: %Schema{type: :integer},
constellation_name: %Schema{type: :string},
security: %Schema{type: :string}
},
required: ["solar_system_id", "solar_system_name"]
}
end
# Map schema with common fields
def map_basic_schema do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
name: %Schema{type: :string},
slug: %Schema{type: :string},
description: %Schema{type: :string},
owner_id: %Schema{type: :string},
inserted_at: %Schema{type: :string, format: :date_time},
updated_at: %Schema{type: :string, format: :date_time}
},
required: ["id", "name", "slug"]
}
end
# License schema
def license_schema do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
license_key: %Schema{type: :string},
is_valid: %Schema{type: :boolean},
expire_at: %Schema{type: :string, format: :date_time},
map_id: %Schema{type: :string}
},
required: ["id", "license_key", "is_valid", "map_id"]
}
end
# Access list schema
def access_list_schema do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
name: %Schema{type: :string},
description: %Schema{type: :string},
owner_id: %Schema{type: :string},
api_key: %Schema{type: :string},
inserted_at: %Schema{type: :string, format: :date_time},
updated_at: %Schema{type: :string, format: :date_time}
},
required: ["id", "name"]
}
end
# Access list member schema
def access_list_member_schema do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
name: %Schema{type: :string},
role: %Schema{type: :string},
eve_character_id: %Schema{type: :string},
eve_corporation_id: %Schema{type: :string},
eve_alliance_id: %Schema{type: :string},
inserted_at: %Schema{type: :string, format: :date_time},
updated_at: %Schema{type: :string, format: :date_time}
},
required: ["id", "name", "role"]
}
end
# Common paginated response wrapper
def paginated_response(items_schema) do
%Schema{
type: :object,
properties: %{
data: items_schema,
pagination: %Schema{
type: :object,
properties: %{
page: %Schema{type: :integer},
page_size: %Schema{type: :integer},
total_pages: %Schema{type: :integer},
total_count: %Schema{type: :integer}
},
required: ["page", "page_size", "total_count"]
}
},
required: ["data", "pagination"]
}
end
end

View File

@@ -0,0 +1,114 @@
defmodule WandererAppWeb.Schemas.ResponseSchemas do
@moduledoc """
Standard response schema definitions for API responses.
This module provides helper functions to create standardized
HTTP response schemas for OpenAPI documentation.
"""
alias WandererAppWeb.Schemas.ApiSchemas
# Standard response status codes
def ok(schema, description \\ "Successful operation") do
{
description,
"application/json",
schema
}
end
def created(schema, description \\ "Resource created") do
{
description,
"application/json",
schema
}
end
def bad_request(description \\ "Bad request") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
def not_found(description \\ "Resource not found") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
def internal_server_error(description \\ "Internal server error") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
def unauthorized(description \\ "Unauthorized") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
def forbidden(description \\ "Forbidden") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
# Helper for common response patterns
def standard_responses(success_schema, success_description \\ "Successful operation") do
[
ok: ok(success_schema, success_description),
bad_request: bad_request(),
not_found: not_found(),
internal_server_error: internal_server_error()
]
end
# Helper for create operation responses
def create_responses(created_schema, created_description \\ "Resource created") do
[
created: created(created_schema, created_description),
bad_request: bad_request(),
internal_server_error: internal_server_error()
]
end
# Helper for update operation responses
def update_responses(updated_schema, updated_description \\ "Resource updated") do
[
ok: ok(updated_schema, updated_description),
bad_request: bad_request(),
not_found: not_found(),
internal_server_error: internal_server_error()
]
end
# Helper for delete operation responses
def delete_responses(deleted_schema \\ nil, deleted_description \\ "Resource deleted") do
if deleted_schema do
[
ok: ok(deleted_schema, deleted_description),
not_found: not_found(),
internal_server_error: internal_server_error()
]
else
[
no_content:
{deleted_description <> " (no content)", nil, nil},
not_found: not_found(),
internal_server_error: internal_server_error()
]
end
end
end

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.62.1"
@version "1.64.3"
def project do
[

View File

@@ -0,0 +1,915 @@
%{
title: "Guide: Systems and Connections API",
author: "Wanderer Team",
cover_image_uri: "/images/news/03-06-systems/api-endpoints.png",
tags: ~w(api map systems connections documentation),
description: "Detailed guide for Wanderer's systems and connections API endpoints, including batch operations, updates, and deletions."
}
---
# Guide to Wanderer's Systems and Connections API
## Introduction
This guide covers Wanderer's dedicated API endpoints for managing systems and connections on your maps. These endpoints provide fine-grained control over individual systems and connections, as well as batch operations for efficient updates.
With these APIs, you can:
- Create, update, and delete individual systems
- Create, update, and delete individual connections
- Perform batch operations on systems and connections
- Query system and connection details
---
## Authentication
All endpoints require a Map API Token, which you can generate in your map settings. Pass the token in the Authorization header:
```bash
Authorization: Bearer <YOUR_MAP_TOKEN>
```
---
## Systems Endpoints
### 1. List Systems
```bash
GET /api/maps/:map_identifier/systems
```
- **Description:** Retrieves all systems and their connections for the specified map.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
```
#### Example Response
```json
{
"data": {
"systems": [
{
"id": "<SYSTEM_UUID>",
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"position_x": 100.5,
"position_y": 200.3,
"status": "clear",
"visible": true,
"description": "Trade hub",
"tag": "TRADE",
"locked": false,
"labels": ["market", "highsec"],
"map_id": "<MAP_UUID>"
}
],
"connections": [
{
"id": "<CONNECTION_UUID>",
"solar_system_source": 30000142,
"solar_system_target": 30000144,
"type": 0,
"mass_status": 0,
"time_status": 0,
"ship_size_type": 1,
"wormhole_type": "K162",
"count_of_passage": 0,
"locked": false,
"custom_info": "Fresh hole"
}
]
}
}
```
### 2. Show Single System
```bash
GET /api/maps/:map_identifier/systems/:id
```
- **Description:** Retrieves details for a specific system.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the system's solar_system_id.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/systems/30000142"
```
#### Example Response
```json
{
"data": {
"id": "<SYSTEM_UUID>",
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"position_x": 100.5,
"position_y": 200.3,
"status": "clear",
"visible": true,
"description": "Trade hub",
"tag": "TRADE",
"locked": false,
"labels": ["market", "highsec"],
"map_id": "<MAP_UUID>"
}
}
```
### 3. Create/Update System
```bash
POST /api/maps/:map_identifier/systems
PUT /api/maps/:map_identifier/systems/:id
```
- **Description:** Creates a new system or updates an existing one.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required for PUT) — the system's solar_system_id.
#### Example Create Request
```bash
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"position_x": 100.5,
"position_y": 200.3,
"status": "clear",
"visible": true,
"description": "Trade hub",
"tag": "TRADE",
"locked": false,
"labels": ["market", "highsec"]
}' \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
```
#### Example Update Request
```bash
curl -X PUT \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"status": "hostile",
"description": "Hostiles reported",
"tag": "DANGER"
}' \
"https://wanderer.example.com/api/maps/your-map-slug/systems/30000142"
```
### 4. Delete System
```bash
DELETE /api/maps/:map_identifier/systems/:id
```
- **Description:** Deletes a specific system and its associated connections.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the system's solar_system_id.
#### Example Request
```bash
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/systems/30000142"
```
### 5. Batch Delete Systems
```bash
DELETE /api/maps/:map_identifier/systems
```
- **Description:** Deletes multiple systems and their connections in a single operation.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"system_ids": [30000142, 30000144, 30000145]
}' \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
```
---
## Connections Endpoints
### 1. List Connections
```bash
GET /api/maps/:map_identifier/connections
```
- **Description:** Retrieves all connections for the specified map.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/connections"
```
#### Example Response
```json
{
"data": [
{
"id": "<CONNECTION_UUID>",
"solar_system_source": 30000142,
"solar_system_target": 30000144,
"type": 0,
"mass_status": 0,
"time_status": 0,
"ship_size_type": 1,
"wormhole_type": "K162",
"count_of_passage": 0,
"locked": false,
}
]
}
```
### 2. Create Connection
```bash
POST /api/maps/:map_identifier/connections
```
- **Description:** Creates a new connection between two systems.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"solar_system_source": 30000142,
"solar_system_target": 30000144,
"type": 0,
"mass_status": 0,
"time_status": 0,
"ship_size_type": 1,
"locked": false,
}' \
"https://wanderer.example.com/api/maps/your-map-slug/connections"
```
### 3. Update Connection
```bash
PATCH /api/maps/:map_identifier/connections
```
- **Description:** Updates an existing connection's properties.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- Query parameters:
- `solar_system_source` (required) — source system ID
- `solar_system_target` (required) — target system ID
#### Example Request
```bash
curl -X PATCH \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"mass_status": 1,
"time_status": 1,
}' \
"https://wanderer.example.com/api/maps/your-map-slug/connections?solar_system_source=30000142&solar_system_target=30000144"
```
### 4. Delete Connection
```bash
DELETE /api/maps/:map_identifier/connections
```
- **Description:** Deletes a connection between two systems.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- Query parameters:
- `solar_system_source` (required) — source system ID
- `solar_system_target` (required) — target system ID
#### Example Request
```bash
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/connections?solar_system_source=30000142&solar_system_target=30000144"
```
---
## Batch Operations
### 1. Batch Upsert Systems and Connections
```bash
POST /api/maps/:map_identifier/systems
```
- **Description:** Creates or updates multiple systems and connections in a single operation.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"systems": [
{
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"position_x": 100.5,
"position_y": 200.3,
"status": "clear"
},
{
"solar_system_id": 30000144,
"solar_system_name": "Perimeter",
"position_x": 150.5,
"position_y": 250.3,
"status": "clear"
}
],
"connections": [
{
"solar_system_source": 30000142,
"solar_system_target": 30000144,
"type": 0,
"mass_status": 0,
"ship_size_type": 1
}
]
}' \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
```
#### Example Response
```json
{
"data": {
"systems": {
"created": 2,
"updated": 0
},
"connections": {
"created": 1,
"updated": 0,
"deleted": 0
}
}
}
```
The response includes counts for:
- Systems created and updated
- Connections created, updated, and deleted (if any)
Note: The `deleted` count in connections will be 0 for batch operations as deletion is handled through separate endpoints.
---
## Practical Examples
### Backup and Restore Map State
We provide a utility script that demonstrates how to use these endpoints to backup and restore your map state:
```bash
#!/bin/bash
# backup_restore_test.sh
# 1. Backup current state
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/systems" \
> map_backup.json
# 2. Delete everything (after confirmation)
read -p "Delete all systems? (y/N) " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
# Get system IDs
systems=$(cat map_backup.json | jq -r '.data.systems[].solar_system_id')
# Create deletion payload
payload=$(jq -n --argjson ids "$(echo "$systems" | jq -R . | jq -s .)" \
'{system_ids: $ids}')
# Delete all systems
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d "$payload" \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
fi
# 3. Restore from backup (after confirmation)
read -p "Restore from backup? (y/N) " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
# Extract systems and connections
backup_data=$(cat map_backup.json)
systems=$(echo "$backup_data" | jq '.data.systems')
connections=$(echo "$backup_data" | jq '.data.connections')
# Create restore payload
payload="{\"systems\": $systems, \"connections\": $connections}"
# Restore everything
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d "$payload" \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
fi
```
This script demonstrates a practical application of the batch operations endpoints for backing up and restoring map data.
---
## Structures Endpoints
### 1. List Structures
```bash
GET /api/maps/:map_identifier/structures
```
- **Description:** Retrieves all structures for the specified map.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/structures"
```
#### Example Response
```json
{
"data": [
{
"id": "<STRUCTURE_UUID>",
"system_id": "<SYSTEM_UUID>",
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"structure_type_id": "35832",
"structure_type": "Astrahus",
"character_eve_id": "123456789",
"name": "Jita Trade Hub",
"notes": "Main market structure",
"owner_name": "Wanderer Corp",
"owner_ticker": "WANDR",
"owner_id": "corp-uuid-1",
"status": "anchoring",
"end_time": "2025-05-01T12:00:00Z",
"inserted_at": "2025-04-30T10:00:00Z",
"updated_at": "2025-04-30T10:00:00Z"
}
]
}
```
### 2. Show Structure
```bash
GET /api/maps/:map_identifier/structures/:id
```
- **Description:** Retrieves details for a specific structure.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the structure's UUID.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/structures/<STRUCTURE_UUID>"
```
#### Example Response
```json
{
"data": {
"id": "<STRUCTURE_UUID>",
"system_id": "<SYSTEM_UUID>",
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"structure_type_id": "35832",
"structure_type": "Astrahus",
"character_eve_id": "123456789",
"name": "Jita Trade Hub",
"notes": "Main market structure",
"owner_name": "Wanderer Corp",
"owner_ticker": "WANDR",
"owner_id": "corp-uuid-1",
"status": "anchoring",
"end_time": "2025-05-01T12:00:00Z",
"inserted_at": "2025-04-30T10:00:00Z",
"updated_at": "2025-04-30T10:00:00Z"
}
}
```
### 3. Create Structure
```bash
POST /api/maps/:map_identifier/structures
```
- **Description:** Creates a new structure.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"structure_type_id": "35832",
"structure_type": "Astrahus",
"character_eve_id": "123456789",
"name": "Jita Trade Hub",
"notes": "Main market structure",
"owner_name": "Wanderer Corp",
"owner_ticker": "WANDR",
"owner_id": "corp-uuid-1",
"status": "anchoring",
"end_time": "2025-05-01T12:00:00Z"
}' \
"https://wanderer.example.com/api/maps/your-map-slug/structures"
```
#### Example Response
```json
{
"data": {
"id": "<STRUCTURE_UUID>",
"system_id": "<SYSTEM_UUID>",
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"structure_type_id": "35832",
"structure_type": "Astrahus",
"character_eve_id": "123456789",
"name": "Jita Trade Hub",
"notes": "Main market structure",
"owner_name": "Wanderer Corp",
"owner_ticker": "WANDR",
"owner_id": "corp-uuid-1",
"status": "anchoring",
"end_time": "2025-05-01T12:00:00Z",
"inserted_at": "2025-04-30T10:00:00Z",
"updated_at": "2025-04-30T10:00:00Z"
}
}
```
### 4. Update Structure
```bash
PUT /api/maps/:map_identifier/structures/:id
```
- **Description:** Updates an existing structure.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the structure's UUID.
#### Example Request
```bash
curl -X PUT \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"status": "anchored",
"notes": "Updated via API"
}' \
"https://wanderer.example.com/api/maps/your-map-slug/structures/<STRUCTURE_UUID>"
```
#### Example Response
```json
{
"data": {
"id": "<STRUCTURE_UUID>",
"status": "anchored",
"notes": "Updated via API"
// ... other fields ...
}
}
```
### 5. Delete Structure
```bash
DELETE /api/maps/:map_identifier/structures/:id
```
- **Description:** Deletes a specific structure.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the structure's UUID.
#### Example Request
```bash
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/structures/<STRUCTURE_UUID>"
```
---
## Signatures Endpoints
### 1. List Signatures
```bash
GET /api/maps/:map_identifier/signatures
```
- **Description:** Retrieves all signatures for the specified map.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/signatures"
```
#### Example Response
```json
{
"data": [
{
"id": "<SIGNATURE_UUID>",
"system_id": "<SYSTEM_UUID>",
"eve_id": "ABC-123",
"name": "Wormhole K162",
"description": "Leads to unknown space",
"type": "Wormhole",
"linked_system_id": 30000144,
"kind": "cosmic_signature",
"group": "wormhole",
"custom_info": "Fresh",
"solar_system_id": 31001394,
"solar_system_name": "J214811",
"character_eve_id": "123456789",
"inserted_at": "2025-04-30T10:00:00Z",
"updated_at": "2025-04-30T10:00:00Z"
}
]
}
```
### 2. Show Signature
```bash
GET /api/maps/:map_identifier/signatures/:id
```
- **Description:** Retrieves details for a specific signature.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the signature's UUID.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/signatures/<SIGNATURE_UUID>"
```
#### Example Response
```json
{
"data": {
"id": "<SIGNATURE_UUID>",
"system_id": "<SYSTEM_UUID>",
"eve_id": "ABC-123",
"name": "Wormhole K162",
"description": "Leads to unknown space",
"type": "Wormhole",
"linked_system_id": 30000144,
"kind": "cosmic_signature",
"group": "wormhole",
"custom_info": "Fresh",
"solar_system_id": 31001394,
"solar_system_name": "J214811",
"character_eve_id": "123456789",
"inserted_at": "2025-04-30T10:00:00Z",
"updated_at": "2025-04-30T10:00:00Z"
}
}
```
### 3. Create Signature
```bash
POST /api/maps/:map_identifier/signatures
```
- **Description:** Creates a new signature.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"eve_id": "ABC-123",
"name": "Wormhole K162",
"description": "Leads to unknown space",
"type": "Wormhole",
"linked_system_id": 30000144,
"kind": "cosmic_signature",
"group": "wormhole",
"custom_info": "Fresh",
"solar_system_id": 31001394,
"solar_system_name": "J214811"
}' \
"https://wanderer.example.com/api/maps/your-map-slug/signatures"
```
#### Example Response
```json
{
"data": {
"id": "<SIGNATURE_UUID>",
"eve_id": "ABC-123",
"name": "Wormhole K162",
"description": "Leads to unknown space",
"type": "Wormhole",
"linked_system_id": 30000144,
"kind": "cosmic_signature",
"group": "wormhole",
"custom_info": "Fresh",
"solar_system_id": 31001394,
"solar_system_name": "J214811",
"character_eve_id": "123456789",
"inserted_at": "2025-04-30T10:00:00Z",
"updated_at": "2025-04-30T10:00:00Z"
}
}
```
### 4. Update Signature
```bash
PUT /api/maps/:map_identifier/signatures/:id
```
- **Description:** Updates an existing signature.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the signature's UUID.
#### Example Request
```bash
curl -X PUT \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"description": "Updated via API",
"custom_info": "Updated info"
}' \
"https://wanderer.example.com/api/maps/your-map-slug/signatures/<SIGNATURE_UUID>"
```
#### Example Response
```json
{
"data": {
"id": "<SIGNATURE_UUID>",
"description": "Updated via API",
"custom_info": "Updated info"
// ... other fields ...
}
}
```
### 5. Delete Signature
```bash
DELETE /api/maps/:map_identifier/signatures/:id
```
- **Description:** Deletes a specific signature.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the signature's UUID.
#### Example Request
```bash
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/signatures/<SIGNATURE_UUID>"
```
---
## Conclusion
These endpoints provide powerful tools for managing your map's systems and connections programmatically. Key features include:
1. Individual system and connection management
2. Efficient batch operations
3. Flexible update options
4. Robust error handling
5. Consistent response formats
For the most up-to-date and interactive documentation, remember to check the Swagger UI at `/swaggerui`.
If you have questions about these endpoints or need assistance, please reach out to the Wanderer Team.
----
Fly safe,
**The Wanderer Team**
----

View File

@@ -0,0 +1,77 @@
%{
title: "Map Active Characters Page — Interface Guide",
author: "Wanderer Team",
cover_image_uri: "/images/news/2025/05-11-map-active-characters/cover.png",
tags: ~w(characters interface guide map security),
description: "This interface is essential for managing access and tracking behavior on shared maps, especially in large organizations."
}
---
### Introduction
![Maps Icon](/images/news/2025/05-11-map-active-characters/cover.png "Maps Icon")
![Map Icon](/images/news/2025/05-11-map-active-characters/map.png "Map Icon")
This page displays **only currently active characters** — those who have the map page open in an active browser tab or window.
### Key Use Cases:
- Identify active pilots on your map
- Monitor user activity and access level
- Manage tracked status to stay within subscription limits
---
## 👤 Character Grouping by User
Each user may have multiple EVE Online characters authorized. On this page:
- Characters are **grouped under their owning user**
- Admins can easily see which characters belong to the same person
- Useful for distinguishing between multiboxers or corp mates sharing access
---
## 📋 Character Info Displayed
![Page](/images/news/2025/05-11-map-active-characters/page.png "Page")
Each tracked character on this page includes:
| Field | Description |
|--------------------|-----------------------------------------------------------------------------|
| **Active From** | Timestamp indicating when the character opened the map (based on real-time browser tab activity) |
| **Character Info** | Character Name, Corporation, and Alliance |
| **Tracked Status** | Whether the character is being actively tracked on the map |
| **Online Status** | Online/offline status (in-game) |
---
## 🔧 Admin Actions
Map **owners and administrators** can:
-**See viewer-only access**: Identify characters who can view but are not being tracked.
- 🚫 **Force Untrack** characters:
- Stop tracking & remove characters from map.
- Useful to stay within your character limit or reset tracking manually.
- Note: The user should re-enable tracking later if needed manually in map tracking settings.
---
## 🛑 Notes & Recommendations
- A character is **counted toward the Characters Limit** of the map's subscription **only when tracked**.
- Tracking begins **as soon as the character opens the map page** in any tab/browser.
- Closing all tabs with the map **automatically stops tracking** for that character (after a small period about 15 minutes).
- This system ensures you always have a live (tracking data automatically updated every 30 seconds), accurate picture of map usage across your team.
---
By using the **Map Active Characters** page, admins can efficiently manage map activity, maintain security, and optimize performance across their team or alliance.
---
Fly safe,
**The Wanderer Team**
---

View File

@@ -340,7 +340,7 @@ groupID,categoryID,groupName,iconID,useBasePrice,anchored,anchorable,fittableNon
471,23,Corporate Hangar Array,0,1,0,1,0,1
472,7,System Scanner,0,0,0,0,0,1
473,23,Tracking Array,0,1,0,1,0,1
474,17,Acceleration Gate Keys,0,0,0,0,0,1
474,17,Acceleration Gate Keys,0,1,0,0,0,1
475,7,Microwarpdrive,96,0,0,0,0,0
476,8,XL Torpedo,1349,0,0,0,1,1
477,9,Mining Barge Blueprint,0,1,0,0,0,1
@@ -1010,7 +1010,7 @@ groupID,categoryID,groupName,iconID,useBasePrice,anchored,anchorable,fittableNon
1405,65,Laboratory,None,0,0,0,0,0
1406,65,Refinery,None,0,1,0,0,1
1407,65,Observatory Array,None,0,0,0,0,0
1408,65,Upwell Jump Gate,None,0,1,0,0,1
1408,65,Upwell Jump Bridge,None,0,1,0,0,1
1409,65,Administration Hub,None,0,0,0,0,0
1410,65,Advertisement Center,None,0,0,0,0,0
1411,11,Amarr Navy Roaming Cruiser,None,0,0,0,0,0
@@ -1295,7 +1295,7 @@ groupID,categoryID,groupName,iconID,useBasePrice,anchored,anchorable,fittableNon
1924,65,♦ Stronghold,None,0,0,0,0,0
1925,11,Irregular Industrial Command Ship,None,0,0,0,0,0
1926,11,Irregular Freighter,None,0,0,0,0,0
1927,11,Irregular Structure,None,0,0,0,0,0
1927,11,Irregular Structure,None,0,1,0,0,0
1928,11,Irregular Container,None,0,0,0,0,0
1929,11,Irregular - Unidentified,None,0,0,0,0,0
1933,66,Structure Composite Reactor Rig M - TE,None,0,0,0,0,1
@@ -1531,6 +1531,10 @@ groupID,categoryID,groupName,iconID,useBasePrice,anchored,anchorable,fittableNon
4821,17,Atavum,None,1,0,0,0,1
4824,17,Infomorph Systems,None,1,0,0,0,1
4825,2,Local Beacon,None,0,1,0,0,0
4827,17,EDENCOM Data,None,1,0,0,0,1
4828,2,Pirate Spawners,None,0,0,0,0,0
4843,17,Limited Rarities,None,1,0,0,0,1
4857,25,Tyranite,15,0,1,0,0,1
350858,350001,Infantry Weapons,None,1,0,0,0,0
351064,350001,Infantry Dropsuits,None,1,0,0,0,0
351121,350001,Infantry Modules,None,1,0,0,0,0
1 groupID categoryID groupName iconID useBasePrice anchored anchorable fittableNonSingleton published
340 471 23 Corporate Hangar Array 0 1 0 1 0 1
341 472 7 System Scanner 0 0 0 0 0 1
342 473 23 Tracking Array 0 1 0 1 0 1
343 474 17 Acceleration Gate Keys 0 0 1 0 0 0 1
344 475 7 Microwarpdrive 96 0 0 0 0 0
345 476 8 XL Torpedo 1349 0 0 0 1 1
346 477 9 Mining Barge Blueprint 0 1 0 0 0 1
1010 1405 65 Laboratory None 0 0 0 0 0
1011 1406 65 Refinery None 0 1 0 0 1
1012 1407 65 Observatory Array None 0 0 0 0 0
1013 1408 65 Upwell Jump Gate Upwell Jump Bridge None 0 1 0 0 1
1014 1409 65 Administration Hub None 0 0 0 0 0
1015 1410 65 Advertisement Center None 0 0 0 0 0
1016 1411 11 Amarr Navy Roaming Cruiser None 0 0 0 0 0
1295 1924 65 ♦ Stronghold None 0 0 0 0 0
1296 1925 11 Irregular Industrial Command Ship None 0 0 0 0 0
1297 1926 11 Irregular Freighter None 0 0 0 0 0
1298 1927 11 Irregular Structure None 0 0 1 0 0 0
1299 1928 11 Irregular Container None 0 0 0 0 0
1300 1929 11 Irregular - Unidentified None 0 0 0 0 0
1301 1933 66 Structure Composite Reactor Rig M - TE None 0 0 0 0 1
1531 4821 17 Atavum None 1 0 0 0 1
1532 4824 17 Infomorph Systems None 1 0 0 0 1
1533 4825 2 Local Beacon None 0 1 0 0 0
1534 4827 17 EDENCOM Data None 1 0 0 0 1
1535 4828 2 Pirate Spawners None 0 0 0 0 0
1536 4843 17 Limited Rarities None 1 0 0 0 1
1537 4857 25 Tyranite 15 0 1 0 0 1
1538 350858 350001 Infantry Weapons None 1 0 0 0 0
1539 351064 350001 Infantry Dropsuits None 1 0 0 0 0
1540 351121 350001 Infantry Modules None 1 0 0 0 0

File diff suppressed because it is too large Load Diff

View File

@@ -5431,12 +5431,12 @@ regionID,constellationID,solarSystemID,solarSystemName,x,y,z,xMin,xMax,yMin,yMax
10000069,20000782,30045353,Pynekastoh,-218294709389294016.0000000000,57890482798287904.0000000000,108746979052662000.0000000000,-215460829363407008.0000000000,-215443912092723008.0000000000,58478339404338496.0000000000,58495256675023104.0000000000,-107169521884028000.0000000000,-107152604613344000.0000000000,1.1560000000,0,0,0,0,0,0,None,0.2396328126,500001,710889342216.0000000000,45047,None
10000069,20000782,30045354,Reitsato,-197894305085120992.0000000000,65621164096811400.0000000000,125071865404206000.0000000000,-192367367542551008.0000000000,-192346438189638016.0000000000,62054618448878800.0000000000,62075547801792096.0000000000,-129051005274330000.0000000000,-129030075921416992.0000000000,0.0106400000,0,0,0,0,0,0,None,0.1895104797,500001,1757422438016.0000000000,8,None
10001000,20010000,30100000,Zarzakh,4732782451200000.0000000000,2722598544370000.0000000000,-1508346782640000.0000000000,4732781451210000.0000000000,4732783451210000.0000000000,2722597544370000.0000000000,2722599544370000.0000000000,-1508347782640000.0000000000,-1508345782640000.0000000000,0E-10,0,0,0,0,0,0,None,-1.0000000000,500029,3112788232450.0000000000,3796,None
11000033,21000334,31000001,J055520,6824391110928870400.0000000000,1700713810475010048.0000000000,-9808807909109499904.0000000000,6824376151141799936.0000000000,6824406070715939840.0000000000,1700698850687940096.0000000000,1700728770262080000.0000000000,-9808822868896569344.0000000000,-9808792949322430464.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,14959787070000.0000000000,34331,None
11000033,21000334,31000002,J110145,6862328572493299712.0000000000,1716315417584930048.0000000000,-9835131116484100096.0000000000,6862313612706230272.0000000000,6862343532280380416.0000000000,1716300457797860096.0000000000,1716330377372000000.0000000000,-9835146076271169536.0000000000,-9835116156697030656.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,14959787070000.0000000000,34331,None
11000033,21000334,31000003,J164710,6800690715289709568.0000000000,1667864493217910016.0000000000,-9815994309006979072.0000000000,6800675755502640128.0000000000,6800705675076780032.0000000000,1667849533430840064.0000000000,1667879453004979968.0000000000,-9816009268794050560.0000000000,-9815979349219909632.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,14959787070000.0000000000,34331,None
11000033,21000334,31000004,J200727,6827372579272930304.0000000000,1725234432784110080.0000000000,-9821296571091089408.0000000000,6827357619485859840.0000000000,6827387539059999744.0000000000,1725219472997040128.0000000000,1725249392571180032.0000000000,-9821311530878160896.0000000000,-9821281611304019968.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,14959787070000.0000000000,34331,None
11000033,21000334,31000001,J055520,6824391110928870400.0000000000,1700713810475010048.0000000000,-9808807909109499904.0000000000,6824376151141799936.0000000000,6824406070715939840.0000000000,1700698850687940096.0000000000,1700728770262080000.0000000000,-9808822868896569344.0000000000,-9808792949322430464.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,500027,14959787070000.0000000000,34331,None
11000033,21000334,31000002,J110145,6862328572493299712.0000000000,1716315417584930048.0000000000,-9835131116484100096.0000000000,6862313612706230272.0000000000,6862343532280380416.0000000000,1716300457797860096.0000000000,1716330377372000000.0000000000,-9835146076271169536.0000000000,-9835116156697030656.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,500002,14959787070000.0000000000,34331,None
11000033,21000334,31000003,J164710,6800690715289709568.0000000000,1667864493217910016.0000000000,-9815994309006979072.0000000000,6800675755502640128.0000000000,6800705675076780032.0000000000,1667849533430840064.0000000000,1667879453004979968.0000000000,-9816009268794050560.0000000000,-9815979349219909632.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,500003,14959787070000.0000000000,34331,None
11000033,21000334,31000004,J200727,6827372579272930304.0000000000,1725234432784110080.0000000000,-9821296571091089408.0000000000,6827357619485859840.0000000000,6827387539059999744.0000000000,1725219472997040128.0000000000,1725249392571180032.0000000000,-9821311530878160896.0000000000,-9821281611304019968.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,500001,14959787070000.0000000000,34331,None
11000031,21000324,31000005,Thera,7201177000000000000.0000000000,1534300000000000000.0000000000,-9501332482538399744.0000000000,7321174000000000000.0000000000,7321180000000000000.0000000000,1533300000000000000.0000000000,1535300000000000000.0000000000,-9394335000000000000.0000000000,-9394329000000000000.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,3112788232446.8598632812,34331,None
11000033,21000334,31000006,J174618,6851740425709679616.0000000000,1679294718115650048.0000000000,-9788179393983350784.0000000000,6851725465922610176.0000000000,6851755385496760320.0000000000,1679279758328580096.0000000000,1679309677902720000.0000000000,-9788194353770420224.0000000000,-9788164434196279296.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,14959787070000.0000000000,34331,None
11000033,21000334,31000006,J174618,6851740425709679616.0000000000,1679294718115650048.0000000000,-9788179393983350784.0000000000,6851725465922610176.0000000000,6851755385496760320.0000000000,1679279758328580096.0000000000,1679309677902720000.0000000000,-9788194353770420224.0000000000,-9788164434196279296.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,500026,14959787070000.0000000000,34331,None
11000001,21000311,31000007,J105443,7644843937233080320.0000000000,-5747794381266180.0000000000,-9482937421590790144.0000000000,7644840278395700224.0000000000,7644847596070469632.0000000000,-5751453218652410.0000000000,-5744135543879960.0000000000,9482933762753400832.0000000000,9482941080428179456.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,3658837386225.7099609375,45038,None
11000001,21000311,31000008,J100744,7646115934669320192.0000000000,45486224544419904.0000000000,-9479841645153390592.0000000000,7646114732911429632.0000000000,7646117136427200512.0000000000,45485022786539904.0000000000,45487426302299800.0000000000,9479840443395510272.0000000000,9479842846911270912.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,1201757879938.6899414062,45033,None
11000001,21000311,31000009,J225046,7639560557040199680.0000000000,-15009347794601900.0000000000,-9477365158238810112.0000000000,7639551529002940416.0000000000,7639569585077449728.0000000000,-15018375831855400.0000000000,-15000319757348400.0000000000,9477356130201550848.0000000000,9477374186276059136.0000000000,0E-10,0,0,0,0,0,0,None,-0.9900000000,None,9028037253510.2304687500,45038,None
Can't render this file because it is too large.

View File

@@ -0,0 +1,163 @@
#!/bin/bash
# test/manual/api/backup_restore_test.sh
# ─── Backup and Restore Test for Map Systems and Connections ────────────────────────
#
# Usage:
# ./backup_restore_test.sh # Run with default settings
# ./backup_restore_test.sh -v # Run in verbose mode
# ./backup_restore_test.sh -h # Show help
#
source "$(dirname "$0")/utils.sh"
# Set to "true" to see detailed output, "false" for minimal output
VERBOSE=${VERBOSE:-false}
# Parse command line options
while getopts "vh" opt; do
case $opt in
v)
VERBOSE=true
;;
h)
echo "Usage: $0 [-v] [-h]"
echo " -v Verbose mode (show detailed output)"
echo " -h Show this help message"
exit 0
;;
\?)
echo "Invalid option: -$OPTARG" >&2
echo "Use -h for help"
exit 1
;;
esac
done
shift $((OPTIND-1))
# File to store backup data
BACKUP_FILE="/tmp/wanderer_map_backup.json"
# ─── UTILITY FUNCTIONS ─────────────────────────────────────────────────────
# Function to backup current map state
backup_map_state() {
echo "==== Backing Up Map State ===="
echo "Fetching current map state..."
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
echo "$response" > "$BACKUP_FILE"
local system_count=$(echo "$response" | jq '.data.systems | length')
local conn_count=$(echo "$response" | jq '.data.connections | length')
echo "✅ Backed up $system_count systems and $conn_count connections to $BACKUP_FILE"
[[ "$VERBOSE" == "true" ]] && echo "Backup data:" && cat "$BACKUP_FILE" | jq '.'
return 0
else
echo "❌ Failed to backup map state. Status: $status"
return 1
fi
}
# Function to delete all systems (which will cascade to connections)
delete_all() {
echo "==== Deleting All Systems ===="
# Get current systems
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
local system_ids=$(echo "$response" | jq -r '.data.systems[].solar_system_id')
if [ -z "$system_ids" ]; then
echo "No systems to delete."
return 0
fi
# Convert system IDs to JSON array and create payload
local system_ids_json=$(echo "$system_ids" | jq -R . | jq -s .)
local payload=$(jq -n --argjson system_ids "$system_ids_json" '{system_ids: $system_ids}')
# Send batch delete request
local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Successfully deleted all systems and their connections"
return 0
else
echo "❌ Failed to delete systems. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
return 1
fi
else
echo "❌ Failed to fetch systems for deletion. Status: $status"
return 1
fi
}
# Function to restore map state from backup
restore_map_state() {
echo "==== Restoring Map State ===="
if [ ! -f "$BACKUP_FILE" ]; then
echo "❌ No backup file found at $BACKUP_FILE"
return 1
fi
local backup_data=$(cat "$BACKUP_FILE")
local systems=$(echo "$backup_data" | jq '.data.systems')
local connections=$(echo "$backup_data" | jq '.data.connections')
# Create payload for batch upsert
local payload="{\"systems\": $systems, \"connections\": $connections}"
# Send batch upsert request
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
local systems_created=$(echo "$response" | jq '.data.systems.created')
local systems_updated=$(echo "$response" | jq '.data.systems.updated')
local conns_created=$(echo "$response" | jq '.data.connections.created')
local conns_updated=$(echo "$response" | jq '.data.connections.updated')
echo "✅ Restore successful:"
echo " Systems: $systems_created created, $systems_updated updated"
echo " Connections: $conns_created created, $conns_updated updated"
return 0
else
echo "❌ Failed to restore map state. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
return 1
fi
}
# ─── MAIN EXECUTION FLOW ─────────────────────────────────────────────────
echo "Starting backup/restore test sequence..."
# Step 1: Backup current state
backup_map_state || { echo "Backup failed, aborting."; exit 1; }
echo -e "\nBackup complete. Press Enter to proceed with deletion..."
read -r
# Step 2: Delete everything
delete_all || { echo "Deletion failed, aborting."; exit 1; }
echo -e "\nDeletion complete. Press Enter to proceed with restore..."
read -r
# Step 3: Restore from backup
restore_map_state || { echo "Restore failed."; exit 1; }
echo -e "\nTest sequence completed."
exit 0

View File

@@ -0,0 +1,462 @@
#!/bin/bash
# test/manual/api/structure_signature_api_tests.sh
# ─── Manual API Tests for Map Structure and Signature APIs ────────────────
#
# Usage:
# ./structure_signature_api_tests.sh # Run all tests with menu selection
# ./structure_signature_api_tests.sh create # Run only creation tests
# ./structure_signature_api_tests.sh update # Run only update tests
# ./structure_signature_api_tests.sh delete # Run only deletion tests
# ./structure_signature_api_tests.sh -v # Run in verbose mode
#
source "$(dirname "$0")/utils.sh"
echo "DEBUG: Script started"
#set -x # Enable shell debug output
VERBOSE=${VERBOSE:-false}
trap 'echo -e "\n❌ ERROR: Script failed at line $LINENO. Last command: $BASH_COMMAND" >&2' ERR
while getopts "vh" opt; do
case $opt in
v)
VERBOSE=true
;;
h)
echo "Usage: $0 [-v] [-h] [all|create|update|delete]"
echo " -v Verbose mode (show detailed test output)"
echo " -h Show this help message"
echo " all Run all tests (default with menu)"
echo " create Run only creation tests"
echo " update Run only update tests"
echo " delete Run only deletion tests"
exit 0
;;
\?)
echo "Invalid option: -$OPTARG" >&2
echo "Use -h for help"
exit 1
;;
esac
done
shift $((OPTIND-1))
COMMAND=${1:-"all"}
STRUCTURES_FILE="/tmp/wanderer_test_structures.txt"
SIGNATURES_FILE="/tmp/wanderer_test_signatures.txt"
CREATED_STRUCTURE_IDS=""
CREATED_SIGNATURE_IDS=""
save_structures() {
echo "DEBUG: Entering save_structures"
if ! echo "$CREATED_STRUCTURE_IDS" > "$STRUCTURES_FILE"; then
echo "ERROR: Failed to write to $STRUCTURES_FILE" >&2
exit 1
fi
echo "DEBUG: Successfully wrote to $STRUCTURES_FILE"
if [[ "$VERBOSE" == "true" ]]; then echo "Saved $(wc -w < "$STRUCTURES_FILE") structures to $STRUCTURES_FILE"; fi
}
load_structures() {
if [ -f "$STRUCTURES_FILE" ]; then
CREATED_STRUCTURE_IDS=$(cat "$STRUCTURES_FILE")
if [[ "$VERBOSE" == "true" ]]; then echo "Loaded $(wc -w < "$STRUCTURES_FILE") structures from $STRUCTURES_FILE"; fi
else
CREATED_STRUCTURE_IDS=""
fi
}
save_signatures() {
echo "$CREATED_SIGNATURE_IDS" > "$SIGNATURES_FILE"
if [[ "$VERBOSE" == "true" ]]; then echo "Saved $(wc -w < "$SIGNATURES_FILE") signatures to $SIGNATURES_FILE"; fi
}
load_signatures() {
if [ -f "$SIGNATURES_FILE" ]; then
CREATED_SIGNATURE_IDS=$(cat "$SIGNATURES_FILE")
if [[ "$VERBOSE" == "true" ]]; then echo "Loaded $(wc -w < "$SIGNATURES_FILE") signatures from $SIGNATURES_FILE"; fi
else
CREATED_SIGNATURE_IDS=""
fi
}
add_to_list() {
local list="$1"
local item="$2"
if [ -z "$list" ]; then
echo "$item"
else
echo "$list $item"
fi
}
# Fetch the first available system (ID and name) from the API
get_first_system() {
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
# Try .data as array
local count=$(echo "$response" | jq -er 'if (.data | type == "array") then (.data | length) else 0 end' 2>/dev/null)
for i in $(seq 0 $((count-1))); do
local uuid=$(echo "$response" | jq -er ".data[$i].id // empty" 2>/dev/null)
local eve_id=$(echo "$response" | jq -er ".data[$i].solar_system_id // empty" 2>/dev/null)
local name=$(echo "$response" | jq -er ".data[$i].name // .data[$i].solar_system_name // empty" 2>/dev/null)
if [[ -n "$uuid" && -n "$eve_id" && -n "$name" ]]; then
echo "$uuid:$eve_id:$name"
return 0
fi
done
# Try .data.systems as array
local count2=$(echo "$response" | jq -er 'if (.data.systems | type == "array") then (.data.systems | length) else 0 end' 2>/dev/null)
for i in $(seq 0 $((count2-1))); do
local uuid=$(echo "$response" | jq -er ".data.systems[$i].id // empty" 2>/dev/null)
local eve_id=$(echo "$response" | jq -er ".data.systems[$i].solar_system_id // empty" 2>/dev/null)
local name=$(echo "$response" | jq -er ".data.systems[$i].name // .data.systems[$i].solar_system_name // empty" 2>/dev/null)
if [[ -n "$uuid" && -n "$eve_id" && -n "$name" ]]; then
echo "$uuid:$eve_id:$name"
return 0
fi
done
echo "ERROR: No valid system found in API response. Available systems:" >&2
echo "$response" | jq '.' >&2
exit 1
else
echo "ERROR: Failed to fetch systems (status $status)" >&2
exit 1
fi
}
# ─── STRUCTURE TESTS ─────────────────────────────────────────────
create_structure() {
local sys_info=$(get_first_system)
local system_uuid=$(echo "$sys_info" | cut -d: -f1)
local eve_system_id=$(echo "$sys_info" | cut -d: -f2)
local system_name=$(echo "$sys_info" | cut -d: -f3-)
echo "==== Creating Structure in system $system_name ($eve_system_id, $system_uuid) ===="
local payload=$(jq -n --arg sid "$eve_system_id" --arg name "$system_name" '{
system_id: "sys-uuid-1",
solar_system_name: $name,
solar_system_id: ($sid|tonumber),
structure_type_id: "35832",
structure_type: "Astrahus",
character_eve_id: "123456789",
name: "Jita Trade Hub",
notes: "Main market structure",
owner_name: "Wanderer Corp",
owner_ticker: "WANDR",
owner_id: "corp-uuid-1",
status: "anchoring",
end_time: "2025-05-05T12:00:00Z"
}')
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/structures" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local id=$(parse_response "$raw" | jq -r '.data.id')
CREATED_STRUCTURE_IDS=$(add_to_list "$CREATED_STRUCTURE_IDS" "$id")
echo "✅ Created structure with ID: $id"
else
echo -e "\n❌ ERROR: Failed to create structure. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
save_structures
echo "DEBUG: End of create_structure, about to return"
}
list_structures() {
echo "==== Listing Structures ===="
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/structures")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local count=$(parse_response "$raw" | jq '.data | length')
echo "✅ Listed $count structures"
if [[ "$VERBOSE" == "true" ]]; then echo "$(parse_response "$raw")" | jq '.'; fi
else
echo -e "\n❌ ERROR: Failed to list structures. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
}
show_structure() {
load_structures
local id=$(echo "$CREATED_STRUCTURE_IDS" | awk '{print $1}')
if [ -z "$id" ]; then
echo -e "\n❌ ERROR: No structure ID found. Run creation first." >&2
exit 1
fi
echo "==== Show Structure $id ===="
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/structures/$id")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local data=$(parse_response "$raw")
local name=$(echo "$data" | jq -r '.data.name')
local status_val=$(echo "$data" | jq -r '.data.status')
local notes=$(echo "$data" | jq -r '.data.notes')
echo "✅ Showed structure $id: name='$name', status='$status_val', notes='$notes'"
if [[ "$VERBOSE" == "true" ]]; then echo "$data" | jq '.'; fi
else
echo -e "\n❌ ERROR: Failed to show structure $id. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
}
update_structure() {
load_structures
local id=$(echo "$CREATED_STRUCTURE_IDS" | awk '{print $1}')
if [ -z "$id" ]; then
echo -e "\n❌ ERROR: No structure ID found. Run creation first." >&2
exit 1
fi
echo "==== Updating Structure $id ===="
local payload=$(jq -n '{status: "anchored", notes: "Updated via test"}')
local raw=$(make_request PUT "$API_BASE_URL/api/maps/$MAP_SLUG/structures/$id" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Updated structure $id"
else
echo -e "\n❌ ERROR: Failed to update structure $id. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
}
delete_structure() {
load_structures
local id=$(echo "$CREATED_STRUCTURE_IDS" | awk '{print $1}')
if [ -z "$id" ]; then
echo -e "\n❌ ERROR: No structure ID found. Run creation first." >&2
exit 1
fi
echo "==== Deleting Structure $id ===="
local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/structures/$id")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Deleted structure $id"
CREATED_STRUCTURE_IDS=""
save_structures
else
echo -e "\n❌ ERROR: Failed to delete structure $id. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
}
# ─── SIGNATURE TESTS ─────────────────────────────────────────────
create_signature() {
local sys_info=$(get_first_system)
echo "DEBUG: sys_info='$sys_info'"
local system_uuid=$(echo "$sys_info" | cut -d: -f1)
local system_id=$(echo "$sys_info" | cut -d: -f2)
local system_name=$(echo "$sys_info" | cut -d: -f3-)
echo "DEBUG: system_id='$system_id' (should be a number like 31001394)"
if [[ -z "$system_id" ]]; then
echo "ERROR: system_id is empty. sys_info='$sys_info'" >&2
exit 1
fi
# Generate a unique, valid-looking eve_id (e.g., ABC-123)
local eve_id=$(cat /dev/urandom | tr -dc 'A-Z' | fold -w 3 | head -n 1)-$(shuf -i 100-999 -n 1)
echo "==== Creating Signature in system $system_name ($system_id, $system_uuid) with eve_id $eve_id ===="
local payload=$(jq -n --arg sid "$system_id" --arg name "$system_name" --arg eve_id "$eve_id" '{
eve_id: $eve_id,
name: "Wormhole K162",
description: "Leads to unknown space",
type: "Wormhole",
linked_system_id: 30000144,
kind: "cosmic_signature",
group: "wormhole",
custom_info: "Fresh",
solar_system_id: ($sid|tonumber),
solar_system_name: $name
}')
echo "DEBUG: payload=$payload"
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/signatures" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
# Now list signatures and find the one with this eve_id
local list_raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/signatures")
local id=$(parse_response "$list_raw" | jq -r --arg eve_id "$eve_id" '.data[] | select(.eve_id == $eve_id) | .id' | head -n 1)
if [[ -z "$id" ]]; then
echo "❌ ERROR: Created signature not found in list (eve_id: $eve_id)" >&2
exit 1
fi
CREATED_SIGNATURE_IDS=$(add_to_list "$CREATED_SIGNATURE_IDS" "$id")
save_signatures
echo "✅ Created signature with eve_id: $eve_id and ID: $id"
else
echo "❌ ERROR: Failed to create signature (status $status)" >&2
echo "$raw" | parse_response | jq . >&2
exit 1
fi
}
list_signatures() {
echo "==== Listing Signatures ===="
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/signatures")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local count=$(parse_response "$raw" | jq '.data | length')
echo "✅ Listed $count signatures"
if [[ "$VERBOSE" == "true" ]]; then echo "$(parse_response "$raw")" | jq '.'; fi
else
echo -e "\n❌ ERROR: Failed to list signatures. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
}
show_signature() {
load_signatures
local id=$(echo "$CREATED_SIGNATURE_IDS" | awk '{print $1}')
if [ -z "$id" ]; then
echo -e "\n❌ ERROR: No signature ID found. Run creation first." >&2
exit 1
fi
echo "==== Show Signature $id ===="
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/signatures/$id")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local data=$(parse_response "$raw")
local eve_id=$(echo "$data" | jq -r '.data.eve_id')
local name=$(echo "$data" | jq -r '.data.name')
local description=$(echo "$data" | jq -r '.data.description')
local custom_info=$(echo "$data" | jq -r '.data.custom_info')
echo "✅ Showed signature $id: eve_id='$eve_id', name='$name', description='$description', custom_info='$custom_info'"
if [[ "$VERBOSE" == "true" ]]; then echo "$data" | jq '.'; fi
else
echo -e "\n❌ ERROR: Failed to show signature $id. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
}
update_signature() {
load_signatures
local id=$(echo "$CREATED_SIGNATURE_IDS" | awk '{print $1}')
if [ -z "$id" ]; then
echo -e "\n❌ ERROR: No signature ID found. Run creation first." >&2
exit 1
fi
# Get the EVE system ID for the update payload
local sys_info=$(get_first_system)
local system_id=$(echo "$sys_info" | cut -d: -f2)
echo "==== Updating Signature $id ===="
local payload=$(jq -n --arg sid "$system_id" '{description: "Updated via test", custom_info: "Updated info", solar_system_id: ($sid|tonumber) }')
local raw=$(make_request PUT "$API_BASE_URL/api/maps/$MAP_SLUG/signatures/$id" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Updated signature $id"
else
echo -e "\n❌ ERROR: Failed to update signature $id. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
}
delete_signature() {
load_signatures
local id=$(echo "$CREATED_SIGNATURE_IDS" | awk '{print $1}')
if [ -z "$id" ]; then
echo -e "\n❌ ERROR: No signature ID found. Run creation first." >&2
exit 1
fi
echo "==== Deleting Signature $id ===="
local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/signatures/$id")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Deleted signature $id"
CREATED_SIGNATURE_IDS=""
save_signatures
else
echo -e "\n❌ ERROR: Failed to delete signature $id. Status: $status" >&2
if [[ "$VERBOSE" == "true" ]]; then echo "Response: $(parse_response "$raw")" >&2; fi
exit 1
fi
}
show_menu() {
echo "===== Map Structure & Signature API Tests ====="
echo "1. Run all tests in sequence (with pauses)"
echo "2. Create structure"
echo "3. List structures"
echo "4. Show structure"
echo "5. Update structure"
echo "6. Delete structure"
echo "7. Create signature"
echo "8. List signatures"
echo "9. Show signature"
echo "10. Update signature"
echo "11. Delete signature"
echo "12. Exit"
echo "==============================================="
echo "Enter your choice [1-12]: "
}
case "$COMMAND" in
"all")
if [ -t 0 ]; then
while true; do
show_menu
read -r choice
case $choice in
1)
create_structure
echo "DEBUG: After calling create_structure in menu, exit code $?"
echo "DEBUG: After create_structure, exit code $?"; read -p "Press Enter to continue..."
list_structures; echo "DEBUG: After list_structures, exit code $?"; read -p "Press Enter to continue..."
show_structure; echo "DEBUG: After show_structure, exit code $?"; read -p "Press Enter to continue..."
update_structure; echo "DEBUG: After update_structure, exit code $?"; read -p "Press Enter to continue..."
show_structure; echo "DEBUG: After show_structure (post-update), exit code $?"; read -p "Press Enter to continue..."
delete_structure; echo "DEBUG: After delete_structure, exit code $?"; read -p "Press Enter to continue..."
create_signature; echo "DEBUG: After create_signature, exit code $?"; read -p "Press Enter to continue..."
list_signatures; echo "DEBUG: After list_signatures, exit code $?"; read -p "Press Enter to continue..."
show_signature; echo "DEBUG: After show_signature, exit code $?"; read -p "Press Enter to continue..."
update_signature; echo "DEBUG: After update_signature, exit code $?"; read -p "Press Enter to continue..."
show_signature; echo "DEBUG: After show_signature (post-update), exit code $?"; read -p "Press Enter to continue..."
delete_signature; echo "DEBUG: After delete_signature, exit code $?"; read -p "Press Enter to continue..."
echo "All tests completed."
show_menu
read -r choice
continue
;;
2) create_structure ;;
3) list_structures ;;
4) show_structure ;;
5) update_structure ;;
6) delete_structure ;;
7) create_signature ;;
8) list_signatures ;;
9) show_signature ;;
10) update_signature ;;
11) delete_signature ;;
12)
read -p "Clean up any remaining test data before exiting? (y/n): " confirm
if [[ "$confirm" =~ ^[Yy] ]]; then
delete_structure
delete_signature
fi
exit 0
;;
*) echo "Invalid option. Please try again." ;;
esac
done
else
create_structure; list_structures; show_structure; update_structure; show_structure; delete_structure
create_signature; list_signatures; show_signature; update_signature; show_signature; delete_signature
fi
;;
"create")
create_structure; create_signature ;;
"update")
update_structure; update_signature ;;
"delete")
delete_structure; delete_signature ;;
*)
echo "Invalid command: $COMMAND"
echo "Use -h for help"
exit 1
;;
esac
exit 0
echo "DEBUG: End of script reached"

View File

@@ -0,0 +1,531 @@
#!/usr/bin/env bash
# ─── Legacy Map endpoint tests ───────────────────────────────────────────────────────
source "$(dirname "${BASH_SOURCE[0]}")/utils.sh"
# Track created IDs for cleanup - use space-delimited strings to match utils.sh
CREATED_SYSTEM_IDS=""
CREATED_CONNECTION_IDS=""
# Optional environment variables to control verbosity:
# VERBOSE_LOGGING=1 - Show full API responses
QUIET_MODE=1 # Show minimal output (just test names and results)
# DUMP RESPONSE - Call this to see the complete raw API response
dump_complete_response() {
local url="$1"
# Only show full response dumps if VERBOSE_LOGGING is set
if [ "${VERBOSE_LOGGING:-0}" -eq 1 ]; then
echo ""
echo "🔍 DUMPING COMPLETE RESPONSE FOR: $url"
echo "────────────────────────────────────────────────────────────────────────────────"
curl -s -H "Authorization: Bearer $API_TOKEN" "$url"
echo ""
echo "────────────────────────────────────────────────────────────────────────────────"
echo ""
else
# In non-verbose mode, just do the curl but don't show output
curl -s -H "Authorization: Bearer $API_TOKEN" "$url" > /dev/null
fi
}
# Initial test to show raw API response structure for system endpoint
test_dump_system_response() {
# If verbose logging is not enabled, skip this test
if [ "${VERBOSE_LOGGING:-0}" -ne 1 ]; then
#echo "Skipping raw response dump (enable with VERBOSE_LOGGING=1)"
return 0
fi
local id="30000142" # Jita
echo "Getting complete raw API response for system ID $id..."
dump_complete_response "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$id"
return 0
}
# Helper function to add element to space-delimited string list
add_to_list() {
local list="$1"
local item="$2"
if [ -z "$list" ]; then
echo "$item"
else
echo "$list $item"
fi
}
# Helper function to count items in a space-delimited list
count_items() {
local list="$1"
if [ -z "$list" ]; then
echo "0"
else
echo "$list" | wc -w
fi
}
# Parse JSON response with error handling
parse_response() {
local raw="$1"
# Skip HTTP headers and get the JSON body
local json_body=$(echo "$raw" | sed '1,/^\s*$/d')
# If JSON is valid, return it. Otherwise, return empty object
if echo "$json_body" | jq . >/dev/null 2>&1; then
echo "$json_body"
else
echo "{}"
fi
}
# Function to get and display detailed system information including visibility
fetch_system_details() {
local system_id=$1
local verbose=${2:-0} # Default to non-verbose mode
# Skip detailed output in quiet mode
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Fetching system details for ID $system_id..."
fi
# Get the complete raw response
local raw
raw=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$system_id")
# Only show raw response in verbose mode
if [ "$verbose" -eq 1 ] && [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Raw response from curl:"
echo "$raw" | jq '.' 2>/dev/null || echo "$raw"
fi
# Extract key information
local name=""
local visible=""
# First attempt to extract from data wrapper
if echo "$raw" | jq -e '.data' >/dev/null 2>&1; then
name=$(echo "$raw" | jq -r '.data.name // .data.solar_system_name // ""')
visible=$(echo "$raw" | jq -r '.data.visible // ""')
else
# Use grep as a last resort
if echo "$raw" | grep -q '"visible":true'; then
visible="true"
elif echo "$raw" | grep -q '"visible":false'; then
visible="false"
fi
if echo "$raw" | grep -q '"name":"[^"]*"'; then
name=$(echo "$raw" | grep -o '"name":"[^"]*"' | head -1 | cut -d':' -f2 | tr -d '"')
fi
fi
# Show results only if not in quiet mode
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "SYSTEM NAME: $name"
echo "VISIBILITY: $visible"
fi
# Return success if we found both name and visibility
if [ ! -z "$name" ] && [ ! -z "$visible" ]; then
return 0
else
return 1
fi
}
test_direct_api_access() {
local raw status
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=$MAP_SLUG")
status=$(parse_status "$raw")
[[ "$status" =~ ^2[0-9]{2}$ ]]
}
test_missing_params() {
local raw status
raw=$(make_request GET "$API_BASE_URL/api/map/systems")
status=$(parse_status "$raw")
[[ "$status" =~ ^4[0-9]{2}$ ]]
}
test_invalid_auth() {
local old="$API_TOKEN" raw status
API_TOKEN="invalid-token"
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=$MAP_SLUG")
status=$(parse_status "$raw")
API_TOKEN="$old"
[[ "$status" == "401" || "$status" == "403" ]]
}
test_invalid_slug() {
local raw status
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=nonexistent")
status=$(parse_status "$raw")
[[ "$status" =~ ^4[0-9]{2}$ ]]
}
# Create and then show systems for legacy API
test_show_systems() {
# Use two well-known systems (use actual EVE IDs for clarity)
local jita_id=30000142 # Jita
local amarr_id=30002187 # Amarr
local success_count=0
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Creating and verifying systems: Jita and Amarr"
fi
# Create first system - Jita with coordinates
local payload raw status response
payload=$(jq -n \
--argjson sid "$jita_id" \
--argjson visible true \
'{solar_system_id:$sid,solar_system_name:"Jita",coordinates:{"x":100,"y":200},visible:$visible}')
# Create the system using the RESTful API
raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
status=$(parse_status "$raw")
if [[ "$status" == "201" || "$status" == "200" ]]; then
success_count=$((success_count + 1))
CREATED_SYSTEM_IDS=$(add_to_list "$CREATED_SYSTEM_IDS" "$jita_id")
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Created Jita system (ID: $jita_id)"
echo "Verifying system $jita_id is visible after creation..."
fi
# Allow a moment for system to be registered
sleep 1
# Verify the system is visible
fetch_system_details "$jita_id"
else
echo "Warning: Couldn't create Jita system, status: $status"
fi
# Create second system - Amarr with coordinates
payload=$(jq -n \
--argjson sid "$amarr_id" \
--argjson visible true \
'{solar_system_id:$sid,solar_system_name:"Amarr",coordinates:{"x":300,"y":400},visible:$visible}')
# Create the system using the RESTful API
raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
status=$(parse_status "$raw")
if [[ "$status" == "201" || "$status" == "200" ]]; then
success_count=$((success_count + 1))
CREATED_SYSTEM_IDS=$(add_to_list "$CREATED_SYSTEM_IDS" "$amarr_id")
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Created Amarr system (ID: $amarr_id)"
echo "Verifying system $amarr_id is visible after creation..."
fi
# Allow a moment for system to be registered
sleep 1
# Verify the system is visible
fetch_system_details "$amarr_id"
else
echo "Warning: Couldn't create Amarr system, status: $status"
fi
# If we couldn't create any systems, test fails
if [ $success_count -eq 0 ]; then
echo "Couldn't create any test systems for legacy API"
return 1
fi
# Verify systems are in the list API
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Checking if systems appear in the list API after creation..."
fi
raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
status=$(parse_status "$raw")
response_body=$(echo "$raw" | sed '1,/^\s*$/d')
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
# Parse the response appropriately depending on structure
local data_array=""
# Check if the response has data array structure
if echo "$response_body" | jq -e '.data' >/dev/null 2>&1; then
data_array=$(echo "$response_body" | jq '.data')
else
data_array="$response_body"
fi
# Check each created system
local all_systems_in_list=true
for sid in $CREATED_SYSTEM_IDS; do
if echo "$data_array" | jq -e ".[] | select(.solar_system_id == $sid)" >/dev/null 2>&1; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ System $sid appears in list API after creation"
fi
else
all_systems_in_list=false
echo "⚠ WARNING: System $sid does not appear in list API after creation"
fi
done
else
echo "ERROR: Failed to get systems list: status $status"
fi
# Now test the legacy API endpoint for each created system
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Verifying systems are accessible via legacy API..."
fi
local legacy_success=true
for sid in $CREATED_SYSTEM_IDS; do
local raw status
raw=$(make_request GET "$API_BASE_URL/api/map/system?id=$sid&slug=$MAP_SLUG")
status=$(parse_status "$raw")
if [[ ! "$status" =~ ^2[0-9]{2}$ ]]; then
echo "Failed to retrieve system $sid via legacy API: status $status"
legacy_success=false
fi
done
if [ "$legacy_success" = "true" ] && [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ All systems accessible via legacy API"
fi
return 0
}
test_verify_connections() {
# Even if we don't have systems, we can still test the legacy connections API endpoint
# by checking that it returns a valid response
local raw status response
# Try to check all connections via legacy API
raw=$(make_request GET "$API_BASE_URL/api/map/connections?slug=$MAP_SLUG")
status=$(parse_status "$raw")
# If the endpoint exists and returns a success status, the test passes
if [[ "$status" =~ ^2[0-9]{2}$ ]]; then
return 0
fi
return 1
}
test_delete_systems() {
# If we don't have system IDs, skip the test
if [ $(count_items "$CREATED_SYSTEM_IDS") -eq 0 ]; then
echo "No systems to delete, skipping"
return 0
fi
local success_count=0
local total_systems=$(count_items "$CREATED_SYSTEM_IDS")
local deleted_ids=""
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "TEST: Delete Systems API"
echo "------------------------"
echo "Testing system deletion for existing systems in map $MAP_SLUG"
echo "Systems to delete: $CREATED_SYSTEM_IDS"
fi
# Try batch delete first
if [ $(count_items "$CREATED_SYSTEM_IDS") -gt 1 ]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Attempting batch delete of systems: $CREATED_SYSTEM_IDS"
fi
local payload=$(echo "$CREATED_SYSTEM_IDS" | tr ' ' '\n' | jq -R . | jq -s '{system_ids: .}')
local raw status
raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems/batch_delete" "$payload")
status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Batch delete successful"
fi
# Verify systems are gone from the list
sleep 1
local list_response
list_response=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
# Check if all systems are gone
local all_deleted=1
for system_id in $CREATED_SYSTEM_IDS; do
if echo "$list_response" | jq -e --arg id "$system_id" '.data[] | select(.solar_system_id == ($id|tonumber) and .visible == true)' >/dev/null 2>&1; then
all_deleted=0
else
success_count=$((success_count + 1))
deleted_ids=$(add_to_list "$deleted_ids" "$system_id")
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ System $system_id no longer visible in list API after batch deletion"
fi
fi
done
if [ $all_deleted -eq 1 ]; then
# Update the list of created systems to remove successfully deleted ones
for id in $deleted_ids; do
CREATED_SYSTEM_IDS=$(echo "$CREATED_SYSTEM_IDS" | sed "s/\b$id\b//g" | tr -s ' ' | sed 's/^ //g' | sed 's/ $//g')
done
# If batch delete worked for all systems, we're done
if [ $success_count -eq $total_systems ]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✅ All systems successfully deleted via batch delete"
fi
return 0
fi
fi
else
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Batch delete failed with status $status, trying individual deletes"
fi
fi
fi
# If batch delete didn't work, try individual deletes
for system_id in $CREATED_SYSTEM_IDS; do
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Attempting to delete system with ID: $system_id"
fi
local raw status
# Use the RESTful DELETE endpoint
raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$system_id")
status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Delete API call successful for system $system_id"
fi
# Allow time for change to propagate
sleep 1
# Get the complete system list after deletion
local list_response
list_response=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
# Check if the system appears in the list (deleted systems shouldn't appear or should be invisible)
local system_still_visible=0
if echo "$list_response" | jq -e --arg id "$system_id" '.data[] | select(.solar_system_id == ($id|tonumber) and .visible == true)' >/dev/null 2>&1; then
system_still_visible=1
fi
if [ $system_still_visible -eq 0 ]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ System $system_id no longer visible in list API after deletion"
fi
success_count=$((success_count + 1))
deleted_ids=$(add_to_list "$deleted_ids" "$system_id")
fi
else
echo "❌ Failed to delete system $system_id: status $status"
fi
done
# Update the list of created systems to remove successfully deleted ones
for id in $deleted_ids; do
CREATED_SYSTEM_IDS=$(echo "$CREATED_SYSTEM_IDS" | sed "s/\b$id\b//g" | tr -s ' ' | sed 's/^ //g' | sed 's/ $//g')
done
# Report results
if [ $success_count -eq $total_systems ]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✅ All systems successfully deleted (no longer visible in list API): $success_count / $total_systems"
fi
return 0
else
echo "⚠ Some systems still appear visible in list API after deletion: $success_count / $total_systems deleted"
return 1
fi
}
# Test the system list API endpoint
test_system_list() {
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Testing system list API endpoint..."
fi
local raw status
raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
status=$(parse_status "$raw")
if [[ "$status" != "200" ]]; then
echo "ERROR: Failed to get system list: status $status"
return 1
fi
# Test legacy system list endpoint too
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Testing legacy system list API endpoint..."
fi
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=$MAP_SLUG")
status=$(parse_status "$raw")
if [[ "$status" != "200" ]]; then
echo "ERROR: Failed to get legacy system list: status $status"
return 1
fi
# Check that both APIs return the same number of systems
local restful_count=$(echo "$raw" | sed '1,/^\s*$/d' | jq '.data | length // length')
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=$MAP_SLUG")
local legacy_count=$(echo "$raw" | sed '1,/^\s*$/d' | jq '.data | length // length')
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "RESTful API returned $restful_count systems, Legacy API returned $legacy_count systems"
fi
if [[ "$restful_count" == "$legacy_count" ]]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Both APIs return the same number of systems"
fi
else
echo "WARNING: APIs return different numbers of systems"
fi
return 0
}
# ─── Execute Tests ────────────────────────────────────────────────────────────
# Function to run a test and report success/failure
run_test() {
local name="$1"
local func="$2"
# Only print test name if not in quiet mode
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo -n "Testing: $name... "
fi
# Run the test function
if $func; then
echo "$name"
return 0
else
echo "$name"
return 1
fi
}
run_test "Dump Raw API Response" test_dump_system_response
run_test "Direct API access" test_direct_api_access
run_test "Missing params (4xx)" test_missing_params
run_test "Invalid auth (401/403)" test_invalid_auth
run_test "Invalid slug on GET" test_invalid_slug
run_test "Show systems" test_show_systems
run_test "System list" test_system_list
run_test "Verify connections" test_verify_connections
run_test "Delete systems" test_delete_systems

View File

@@ -0,0 +1,682 @@
#!/bin/bash
# test/manual/api/improved_api_tests.sh
# ─── Improved API Tests for Map System and Connection APIs ────────────────────────
#
# Usage:
# ./improved_api_tests.sh # Run all tests with menu selection
# ./improved_api_tests.sh create # Run only creation tests
# ./improved_api_tests.sh update # Run only update tests
# ./improved_api_tests.sh delete # Run only deletion tests
# ./improved_api_tests.sh -v # Run in verbose mode
#
source "$(dirname "$0")/utils.sh"
# Set to "true" to see detailed output, "false" for minimal output
VERBOSE=${VERBOSE:-false}
# Parse command line options
while getopts "vh" opt; do
case $opt in
v)
VERBOSE=true
;;
h)
echo "Usage: $0 [-v] [-h] [all|create|update|delete]"
echo " -v Verbose mode (show detailed test output)"
echo " -h Show this help message"
echo " all Run all tests (default with menu)"
echo " create Run only creation tests"
echo " update Run only update tests"
echo " delete Run only deletion tests"
exit 0
;;
\?)
echo "Invalid option: -$OPTARG" >&2
echo "Use -h for help"
exit 1
;;
esac
done
shift $((OPTIND-1))
COMMAND=${1:-"all"}
# File to store system and connection IDs for persistence between command runs
SYSTEMS_FILE="/tmp/wanderer_test_systems.txt"
CONNECTIONS_FILE="/tmp/wanderer_test_connections.txt"
# Track created IDs for cleanup
CREATED_SYSTEM_IDS=""
CREATED_CONNECTION_IDS=""
# Array of valid EVE system IDs and names (first 5 for individual creation)
declare -a EVE_SYSTEMS=(
"30005304:Alentene"
"30003380:Alf"
"30003811:Algasienan"
"30004972:Algogille"
"30002698:Aliette"
)
# Next 5 for batch upsert
declare -a BATCH_EVE_SYSTEMS=(
"30002754:Alikara"
"30002712:Alillere"
"30003521:Alkabsi"
"30000034:Alkez"
"30004995:Allamotte"
)
# ─── UTILITY FUNCTIONS ─────────────────────────────────────────────────────
# Function to save created system IDs to file
save_systems() {
echo "$CREATED_SYSTEM_IDS" > "$SYSTEMS_FILE"
[[ "$VERBOSE" == "true" ]] && echo "Saved $(wc -w < "$SYSTEMS_FILE") systems to $SYSTEMS_FILE"
}
# Function to load system IDs from file
load_systems() {
if [ -f "$SYSTEMS_FILE" ]; then
CREATED_SYSTEM_IDS=$(cat "$SYSTEMS_FILE")
[[ "$VERBOSE" == "true" ]] && echo "Loaded $(wc -w < "$SYSTEMS_FILE") systems from $SYSTEMS_FILE"
else
echo "No systems file found at $SYSTEMS_FILE. Run creation tests first."
CREATED_SYSTEM_IDS=""
fi
}
# Function to save created connection IDs to file
save_connections() {
echo "$CREATED_CONNECTION_IDS" > "$CONNECTIONS_FILE"
[[ "$VERBOSE" == "true" ]] && echo "Saved $(wc -w < "$CONNECTIONS_FILE") connections to $CONNECTIONS_FILE"
}
# Function to load connection IDs from file
load_connections() {
if [ -f "$CONNECTIONS_FILE" ]; then
CREATED_CONNECTION_IDS=$(cat "$CONNECTIONS_FILE")
[[ "$VERBOSE" == "true" ]] && echo "Loaded $(wc -w < "$CONNECTIONS_FILE") connections from $CONNECTIONS_FILE"
else
echo "No connections file found at $CONNECTIONS_FILE. Run creation tests first."
CREATED_CONNECTION_IDS=""
fi
}
# Function to add item to space-delimited list
add_to_list() {
local list="$1"
local item="$2"
if [ -z "$list" ]; then
echo "$item"
else
echo "$list $item"
fi
}
# ─── TEST FUNCTIONS ─────────────────────────────────────────────────────
# FUNCTION: Create systems
create_systems() {
echo "==== Creating Systems ===="
local system_count=0
local center_x=500
local center_y=500
local radius=250
# Only clear the systems file if we're starting fresh
> "$SYSTEMS_FILE"
CREATED_SYSTEM_IDS=""
# Build all system payloads as a JSON array
local systems_payload="["
local num_systems=${#EVE_SYSTEMS[@]}
for i in $(seq 0 $((num_systems-1))); do
IFS=':' read -r system_id system_name <<< "${EVE_SYSTEMS[$i]}"
local angle=$(echo "scale=6; $i * 6.28318 / $num_systems" | bc -l)
local x=$(echo "scale=2; $center_x + $radius * c($angle)" | bc -l)
local y=$(echo "scale=2; $center_y + $radius * s($angle)" | bc -l)
local system_json=$(jq -n \
--argjson sid "$system_id" \
--arg name "$system_name" \
--argjson x "$x" \
--argjson y "$y" \
'{
solar_system_id: $sid,
solar_system_name: $name,
position_x: $x,
position_y: $y,
status: "clear",
visible: true,
description: "Test system",
tag: "TEST",
locked: false
}')
systems_payload+="$system_json"
if [ $i -lt $((num_systems-1)) ]; then
systems_payload+=","
fi
done
systems_payload+="]"
# Wrap in the 'systems' key
local payload="{\"systems\": $systems_payload}"
# Send the batch create request
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Created all systems in batch"
# Track the system IDs for later cleanup
for i in $(seq 0 $((num_systems-1))); do
IFS=':' read -r system_id _ <<< "${EVE_SYSTEMS[$i]}"
CREATED_SYSTEM_IDS=$(add_to_list "$CREATED_SYSTEM_IDS" "$system_id")
system_count=$((system_count+1))
done
else
echo "❌ Failed to create systems in batch. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
echo "Total systems created: $system_count/$num_systems"
save_systems
# Validate actual state after creation
echo "Validating systems after dedicated creation:"
list_systems_and_connections
}
# FUNCTION: Create connections
create_connections() {
echo "==== Creating Connections ===="
load_systems
if [ -z "$CREATED_SYSTEM_IDS" ]; then
echo "No systems available. Run system creation first."
return 1
fi
> "$CONNECTIONS_FILE"
CREATED_CONNECTION_IDS=""
local connection_count=0
local total_connections=0
local system_array=($CREATED_SYSTEM_IDS)
echo "Testing dedicated connection endpoints..."
# Create connections one by one using the dedicated endpoint
for i in $(seq 0 $((${#system_array[@]}-1))); do
local source=${system_array[$i]}
local target=${system_array[$(( (i+1) % ${#system_array[@]} ))]}
total_connections=$((total_connections+1))
# Create single connection payload
local payload=$(jq -n \
--argjson source "$source" \
--argjson target "$target" \
'{
solar_system_source: $source,
solar_system_target: $target,
type: 0,
mass_status: 0,
time_status: 0,
ship_size_type: 1,
wormhole_type: "K162",
count_of_passage: 0
}')
# Send create request to dedicated endpoint
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/connections" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Created connection from $source to $target"
local response=$(parse_response "$raw")
# Store source and target for later use
CREATED_CONNECTION_IDS=$(add_to_list "$CREATED_CONNECTION_IDS" "${source}:${target}")
connection_count=$((connection_count+1))
else
echo "❌ Failed to create connection from $source to $target. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
done
echo "Total connections created via dedicated endpoint: $connection_count/$total_connections"
save_connections
# Always validate actual state after connection creation
echo "Validating connections after dedicated creation:"
list_systems_and_connections
echo -e "\nTesting batch upsert functionality..."
# Build batch upsert payload using BATCH_EVE_SYSTEMS
local batch_systems_json="["
local batch_connections_json="["
local num_batch_systems=${#BATCH_EVE_SYSTEMS[@]}
for i in $(seq 0 $((num_batch_systems-1))); do
IFS=':' read -r system_id system_name <<< "${BATCH_EVE_SYSTEMS[$i]}"
local angle=$(echo "scale=6; $i * 6.28318 / $num_batch_systems" | bc -l)
local x=$(echo "scale=2; 500 + 250 * c($angle)" | bc -l)
local y=$(echo "scale=2; 500 + 250 * s($angle)" | bc -l)
local system_json=$(jq -n \
--argjson sid "$system_id" \
--arg name "$system_name" \
--argjson x "$x" \
--argjson y "$y" \
'{
solar_system_id: $sid,
solar_system_name: $name,
position_x: $x,
position_y: $y,
status: "clear",
visible: true,
description: "Test system (batch)",
tag: "BATCH",
locked: false
}')
batch_systems_json+="$system_json"
if [ $i -lt $((num_batch_systems-1)) ]; then
batch_systems_json+=","
fi
# Build connections in a ring
local source=$system_id
local next_index=$(( (i+1) % num_batch_systems ))
IFS=':' read -r target_id _ <<< "${BATCH_EVE_SYSTEMS[$next_index]}"
batch_connections_json+="{\"solar_system_source\":$source,\"solar_system_target\":$target_id,\"mass_status\":0,\"ship_size_type\":1,\"type\":0}"
if [ $i -lt $((num_batch_systems-1)) ]; then
batch_connections_json+=","
fi
done
batch_systems_json+="]"
batch_connections_json+="]"
echo "[SCRIPT] Batch upsert systems: $batch_systems_json"
echo "[SCRIPT] Batch upsert connections: $batch_connections_json"
# Check for API_TOKEN
if [ -z "$API_TOKEN" ]; then
echo "❌ API_TOKEN is not set. Please export API_TOKEN before running the script."
return 1
fi
# Send batch upsert request
local response=$(curl -s -X POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_TOKEN" \
-d "{\"systems\":$batch_systems_json,\"connections\":$batch_connections_json}")
echo "[SCRIPT] Batch upsert response: $response"
# Debug: List all connections after batch upsert
echo "[SCRIPT] Listing all connections after batch upsert:"
local list_raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
local list_status=$(parse_status "$list_raw")
if [[ "$list_status" =~ ^2[0-9][0-9]$ ]]; then
local list_response=$(parse_response "$list_raw")
echo "$list_response" | jq -c '.data.connections[] | {id: .id, source: .solar_system_source, target: .solar_system_target, mass_status: .mass_status, ship_size_type: .ship_size_type, type: .type}'
else
echo "[SCRIPT] Failed to list connections after batch upsert. Status: $list_status"
fi
# Add batch system IDs to CREATED_SYSTEM_IDS
for i in $(seq 0 $((num_batch_systems-1))); do
IFS=':' read -r system_id _ <<< "${BATCH_EVE_SYSTEMS[$i]}"
CREATED_SYSTEM_IDS=$(add_to_list "$CREATED_SYSTEM_IDS" "$system_id")
done
# Add batch connection pairs to CREATED_CONNECTION_IDS
for i in $(seq 0 $((num_batch_systems-1))); do
IFS=':' read -r source _ <<< "${BATCH_EVE_SYSTEMS[$i]}"
next_index=$(( (i+1) % num_batch_systems ))
IFS=':' read -r target _ <<< "${BATCH_EVE_SYSTEMS[$next_index]}"
CREATED_CONNECTION_IDS=$(add_to_list "$CREATED_CONNECTION_IDS" "${source}:${target}")
done
save_systems
save_connections
list_systems_and_connections
echo "Total connections updated: $connection_count/${#system_array[@]}"
}
# FUNCTION: Update systems
update_systems() {
echo "==== Updating Systems ===="
load_systems
if [ -z "$CREATED_SYSTEM_IDS" ]; then
echo "No systems available. Run system creation first."
return 1
fi
local update_count=0
local system_array=($CREATED_SYSTEM_IDS)
local num_systems=${#system_array[@]}
for i in $(seq 0 $((num_systems-1))); do
local system_id=${system_array[$i]}
# Get system name from EVE_SYSTEMS array if available
local system_name="System $system_id"
for j in $(seq 0 $((${#EVE_SYSTEMS[@]}-1))); do
IFS=':' read -r curr_id curr_name <<< "${EVE_SYSTEMS[$j]}"
if [ "$curr_id" = "$system_id" ]; then
system_name=$curr_name
break
fi
done
echo "Updating system $((i+1))/$num_systems: $system_name (ID: $system_id)"
# Create update payload with new values
local status_values=("clear" "friendly" "hostile" "occupied")
local status=${status_values[$((RANDOM % 4))]}
local desc="Updated description for $system_name"
local tag="UPDATED"
local payload=$(jq -n \
--arg status "$status" \
--arg desc "$desc" \
--arg tag "$tag" \
'{
status: $status,
description: $desc,
tag: $tag,
locked: false
}')
# Send the update request
local raw=$(make_request PUT "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$system_id" "$payload")
local status_code=$(parse_status "$raw")
if [[ "$status_code" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Updated system $system_name with status: $status"
update_count=$((update_count+1))
else
echo "❌ Failed to update system $system_name. Status: $status_code"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
done
echo "Total systems updated: $update_count/$num_systems"
}
# FUNCTION: Update connections
update_connections() {
echo "==== Updating Connections ===="
load_systems
load_connections
if [ -z "$CREATED_SYSTEM_IDS" ] || [ -z "$CREATED_CONNECTION_IDS" ]; then
echo "No systems or connections available. Run creation tests first."
return 1
fi
echo "Testing connection updates..."
local update_count=0
local conn_array=($CREATED_CONNECTION_IDS)
for triple in "${conn_array[@]}"; do
local source=$(echo $triple | cut -d: -f1)
local target=$(echo $triple | cut -d: -f2)
# Create update payload
local mass_values=(0 1 2)
local ship_values=(0 1 2 3)
local mass=${mass_values[$((RANDOM % 3))]}
local ship=${ship_values[$((RANDOM % 4))]}
local payload=$(jq -n \
--argjson mass "$mass" \
--argjson ship "$ship" \
'{
mass_status: $mass,
ship_size_type: $ship
}')
# Try source/target update
local raw=$(make_request PATCH "$API_BASE_URL/api/maps/$MAP_SLUG/connections?solar_system_source=$source&solar_system_target=$target" "$payload")
local status_code=$(parse_status "$raw")
if [[ "$status_code" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Updated connection $source->$target"
update_count=$((update_count+1))
else
echo "❌ Failed to update connection $source->$target. Status: $status_code"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
done
echo "Total connections updated: $update_count/${#conn_array[@]}"
echo -e "\nTesting batch connection updates..."
# Create batch update payload for all connections
local batch_connections="["
local first=true
for triple in "${conn_array[@]}"; do
local source=$(echo $triple | cut -d: -f1)
local target=$(echo $triple | cut -d: -f2)
local mass=${mass_values[$((RANDOM % 3))]}
local ship=${ship_values[$((RANDOM % 4))]}
if [ "$first" = true ]; then
first=false
else
batch_connections+=","
fi
batch_connections+=$(jq -n \
--argjson source "$source" \
--argjson target "$target" \
--argjson mass "$mass" \
--argjson ship "$ship" \
'{
solar_system_source: $source,
solar_system_target: $target,
mass_status: $mass,
ship_size_type: $ship
}')
done
batch_connections+="]"
local batch_payload="{\"connections\": $batch_connections}"
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$batch_payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
local updated_count=$(echo "$response" | jq '.data.connections.updated')
if [ "$updated_count" != "null" ]; then
echo "✅ Batch update successful - Updated connections: $updated_count"
else
echo "❌ Batch update returned null for updated count"
fi
else
echo "❌ Batch update failed. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
}
# FUNCTION: List systems and connections
list_systems_and_connections() {
echo "==== Listing Systems and Connections ===="
load_systems
if [ -z "$CREATED_SYSTEM_IDS" ]; then
echo "No systems available. Run system creation first."
return 1
fi
echo "Testing list all systems and connections endpoint"
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
local system_count=$(echo "$response" | jq '.data.systems | length')
local conn_count=$(echo "$response" | jq '.data.connections | length')
echo "✅ Listed $system_count systems and $conn_count connections"
[[ "$VERBOSE" == "true" ]] && echo "$response" | jq '.'
return 0
else
echo "❌ Failed to list systems and connections. Status: $status"
return 1
fi
}
# FUNCTION: Delete connections and systems
delete_everything() {
echo "==== Deleting Connections and Systems ===="
load_connections
load_systems
echo "Cleaning up connections..."
# Delete connections using source/target pairs
local conn_array=($CREATED_CONNECTION_IDS)
for triple in "${conn_array[@]}"; do
local source=$(echo $triple | cut -d: -f1)
local target=$(echo $triple | cut -d: -f2)
local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/connections?solar_system_source=$source&solar_system_target=$target")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Deleted connection $source->$target"
else
echo "❌ Failed to delete connection $source->$target. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
done
echo "Cleaning up systems..."
# Use batch delete for systems
local system_array=($CREATED_SYSTEM_IDS)
echo "Attempting batch delete of systems..."
echo "System ${system_array[@]}"
local system_ids_json=$(printf '%s\n' "${system_array[@]}" | jq -R . | jq -s .)
local payload=$(jq -n --argjson system_ids "$system_ids_json" '{system_ids: $system_ids}')
local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Batch delete successful for all systems"
> "$SYSTEMS_FILE"
> "$CONNECTIONS_FILE"
CREATED_SYSTEM_IDS=""
CREATED_CONNECTION_IDS=""
else
echo "❌ Batch delete failed. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
}
# ─── MENU AND INTERACTION LOGIC ─────────────────────────────────────────
show_menu() {
echo "===== Map System and Connection API Tests ====="
echo "1. Run all tests in sequence (with pauses)"
echo "2. Create systems"
echo "3. Create connections"
echo "4. Update systems"
echo "5. Update connections"
echo "6. List systems and connections"
echo "7. Delete everything"
echo "8. Exit"
echo "================================================"
echo "Enter your choice [1-8]: "
}
# ─── MAIN EXECUTION FLOW ─────────────────────────────────────────────────
# Main execution based on command
case "$COMMAND" in
"all")
# If no specific command was provided, show the menu
if [ -t 0 ]; then # Only show menu if running interactively
# Interactive mode with menu
while true; do
show_menu
read -r choice
case $choice in
1)
# Run all tests in sequence with pauses
create_systems || echo "System creation failed/skipped"
echo "Press Enter to continue with connection creation..."
read -r
create_connections || echo "Connection creation failed/skipped"
echo "Press Enter to continue with system updates..."
read -r
update_systems || echo "System update failed/skipped"
echo "Press Enter to continue with connection updates..."
read -r
update_connections || echo "Connection update failed/skipped"
echo "Press Enter to continue with listing tests..."
read -r
list_systems_and_connections || echo "Listing failed/skipped"
echo "Press Enter to continue with deletion..."
read -r
delete_everything || echo "Cleanup failed/skipped"
echo "All tests completed."
;;
2)
create_systems
;;
3)
create_connections
;;
4)
update_systems
;;
5)
update_connections
;;
6)
list_systems_and_connections
;;
7)
delete_everything
;;
8)
# Offer to clean up before exiting
read -p "Clean up any remaining test data before exiting? (y/n): " confirm
if [[ "$confirm" =~ ^[Yy] ]]; then
delete_everything
fi
exit 0
;;
*)
echo "Invalid option. Please try again."
;;
esac
done
else
# Non-interactive mode, run all tests in sequence
create_systems || echo "System creation failed/skipped"
create_connections || echo "Connection creation failed/skipped"
update_systems || echo "System update failed/skipped"
update_connections || echo "Connection update failed/skipped"
list_systems_and_connections || echo "Listing failed/skipped"
delete_everything || echo "Cleanup failed/skipped"
fi
;;
"create")
create_systems
create_connections
;;
"update")
update_systems
update_connections
list_systems_and_connections
;;
"delete")
delete_everything
;;
*)
echo "Invalid command: $COMMAND"
echo "Use -h for help"
exit 1
;;
esac
exit 0

167
test/manual/api/utils.sh Executable file
View File

@@ -0,0 +1,167 @@
#!/bin/bash
set -eu
# ─── Dependencies ─────────────────────────────────────────────────────────────
for cmd in curl jq; do
if ! command -v "$cmd" > /dev/null 2>&1; then
echo "Error: '$cmd' is required" >&2
exit 1
fi
done
# ─── Load .env if present ─────────────────────────────────────────────────────
load_env_file() {
echo "📄 Loading env file: $1"
set -o allexport
source "$1"
set +o allexport
}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -f "$SCRIPT_DIR/.env" ]; then
load_env_file "$SCRIPT_DIR/.env"
fi
# Check if API_TOKEN is set
: "${API_TOKEN:?Error: API_TOKEN environment variable not set}"
# ─── HTTP Request Helper ──────────────────────────────────────────────────────
make_request() {
local method=$1 url=$2 data=${3:-}
local curl_cmd=(curl -s -w $'\n%{http_code}' -H "Authorization: Bearer $API_TOKEN")
if [ "$method" != "GET" ]; then
curl_cmd+=(-X "$method" -H "Content-Type: application/json")
fi
if [ -n "$data" ]; then
curl_cmd+=(-d "$data")
fi
"${curl_cmd[@]}" "$url"
}
# ─── Response Parsers ─────────────────────────────────────────────────────────
parse_response() { # strips the final newline+status line
local raw="$1"
echo "${raw%$'\n'*}"
}
parse_status() { # returns only the status code (last line)
local raw="$1"
echo "${raw##*$'\n'}"
}
# ─── Assertion Helper ─────────────────────────────────────────────────────────
verify_http_code() {
local got=$1 want=$2 label=$3
if [ "$got" -eq "$want" ]; then
return 0
else
echo "🚫 $label: expected HTTP $want, got $got" >&2
return 1
fi
}
# ─── Test Runner & Summary ────────────────────────────────────────────────────
# Only initialize counters once to accumulate across multiple suite sources
if [ -z "${TOTAL_TESTS+x}" ]; then
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
FAILED_LIST=""
fi
run_test() {
local label=$1 fn=$2
TOTAL_TESTS=$((TOTAL_TESTS+1))
if "$fn"; then
echo "$label"
PASSED_TESTS=$((PASSED_TESTS+1))
else
echo "$label"
FAILED_TESTS=$((FAILED_TESTS+1))
FAILED_LIST="$FAILED_LIST $label"
fi
}
# ─── Cleanup on Exit ──────────────────────────────────────────────────────────
CREATED_SYSTEM_IDS=""
CREATED_CONNECTION_IDS=""
cleanup_map_systems() {
# First delete connections
if [ -n "$CREATED_CONNECTION_IDS" ]; then
echo "Cleaning up connections..."
for conn_id in $CREATED_CONNECTION_IDS; do
# Try with a direct DELETE request to the connection endpoint
make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/connections/$conn_id" > /dev/null 2>&1 || true
done
fi
# Then delete systems
if [ -n "$CREATED_SYSTEM_IDS" ]; then
echo "Cleaning up systems..."
# First try batch delete if we have multiple systems
if [ $(echo "$CREATED_SYSTEM_IDS" | wc -w) -gt 1 ]; then
echo "Attempting batch delete of systems..."
# Use the official batch_delete endpoint
local payload=$(echo "$CREATED_SYSTEM_IDS" | tr ' ' '\n' | jq -R . | jq -s '{system_ids: .}')
local raw
raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems/batch_delete" "$payload" 2>/dev/null) || true
# Check if batch delete was successful by looking for systems
sleep 1
local success=1
for sys_id in $CREATED_SYSTEM_IDS; do
# Check if system still exists and is visible
local check=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
if echo "$check" | grep -q "\"solar_system_id\":$sys_id"; then
if echo "$check" | grep -q "\"solar_system_id\":$sys_id.*\"visible\":true"; then
success=0
else
echo "System $sys_id exists but is not visible (batch delete worked)"
fi
else
echo "System $sys_id no longer found (batch delete worked)"
fi
done
# If batch delete was successful for all systems, we're done
if [ $success -eq 1 ]; then
echo "✅ Batch delete successful for all systems"
return 0
fi
fi
# If batch delete failed or we have only one system, try individual deletes
echo "Performing individual system deletions..."
for sys_id in $CREATED_SYSTEM_IDS; do
echo "Deleting system $sys_id..."
# Try standard DELETE request
make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$sys_id" > /dev/null 2>&1 || true
# Verify the system was deleted or at least made invisible
sleep 1
local check=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
if echo "$check" | grep -q "\"solar_system_id\":$sys_id"; then
if echo "$check" | grep -q "\"solar_system_id\":$sys_id.*\"visible\":true"; then
echo "⚠️ System $sys_id is still visible after all deletion attempts"
else
echo "System $sys_id exists but is not visible (deletion worked)"
fi
else
echo "System $sys_id no longer found (deletion worked)"
fi
done
fi
}
#trap cleanup_map_systems EXIT