Compare commits

..

46 Commits

Author SHA1 Message Date
CI
a5850b5a8d chore: release version v1.90.5 2025-12-12 18:36:03 +00:00
Dmitry Popov
9f6849209b fix(core): fixed map scopes 2025-12-12 19:35:26 +01:00
CI
7bd295cbad chore: [skip ci] 2025-12-12 17:07:55 +00:00
CI
078e5fc19e chore: release version v1.90.4 2025-12-12 17:07:55 +00:00
Dmitry Popov
3877e121c3 fix(core): fixed map scopes & signatures clean up behaviour 2025-12-12 18:07:18 +01:00
CI
dcb2a0cdb2 chore: [skip ci] 2025-12-11 00:17:06 +00:00
CI
f5294eee84 chore: release version v1.90.3 2025-12-11 00:17:06 +00:00
Dmitry Popov
a5c87b6fa4 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-11 01:16:27 +01:00
Dmitry Popov
eae275f515 fix(core): added pagination for long ACL lists 2025-12-11 01:16:24 +01:00
CI
68ae6706dd chore: [skip ci] 2025-12-10 23:56:28 +00:00
CI
a34b30af15 chore: release version v1.90.2 2025-12-10 23:56:28 +00:00
Dmitry Popov
38b49266ed fix(core): added system position updates to SSE
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-12-11 00:55:52 +01:00
CI
049884bb4c chore: [skip ci] 2025-12-08 21:56:20 +00:00
CI
3c75b2b59f chore: release version v1.90.1 2025-12-08 21:56:20 +00:00
Dmitry Popov
4ad5d191a3 fix(core): fixed connections and signatures remove issues, added comprehensive audit log for auto removed connections and signatures 2025-12-08 22:55:39 +01:00
CI
2499c24cc1 chore: [skip ci] 2025-12-06 10:58:14 +00:00
CI
6f0043205c chore: release version v1.90.0 2025-12-06 10:58:14 +00:00
Dmitry Popov
597741fa60 Merge pull request #567 from wanderer-industries/develop
Develop
2025-12-06 14:57:27 +04:00
Dmitry Popov
d313ae8cd2 fix(core): fixed clean up for linked signatures
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-12-04 11:33:42 +01:00
Dmitry Popov
06d5d8072e fix(core): fixed issue with default select mode
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-12-03 12:21:40 +01:00
CI
f2d112df5c chore: [skip ci] 2025-12-02 23:44:54 +00:00
CI
716604fa84 chore: release version v1.89.6 2025-12-02 23:44:54 +00:00
Dmitry Popov
cae958a1e6 Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-12-03 00:44:31 +01:00
Dmitry Popov
283b36c882 fix(kills): fixed zkb links (added "allow-popups-to-escape-sandbox" to CSP) 2025-12-03 00:44:23 +01:00
Dmitry Popov
051e71f1a6 Merge pull request #566 from guarzo/guarzo/sigapi
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
fix: apiV1 default fields updates
2025-12-03 00:20:13 +04:00
Guarzo
20a50e8db0 fix: apiV1 default fields updates 2025-12-02 17:55:05 +00:00
CI
79d7f7ce7d chore: [skip ci] 2025-12-02 12:46:26 +00:00
CI
6c4b65c446 chore: release version v1.89.5 2025-12-02 12:46:26 +00:00
Dmitry Popov
2b07af5e12 Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-12-02 13:45:57 +01:00
Dmitry Popov
d0901eecb4 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-02 13:45:51 +01:00
Dmitry Popov
ee85d29c54 chore: added tests 2025-12-02 13:45:47 +01:00
Dmitry Popov
a237d6513d Merge branch 'main' into develop 2025-12-02 13:37:13 +01:00
CI
02979588c1 chore: [skip ci] 2025-12-02 12:35:31 +00:00
CI
3abe40855f chore: release version v1.89.4 2025-12-02 12:35:30 +00:00
Dmitry Popov
d0d9418a89 fix(core): fixed acl character update issues 2025-12-02 13:34:55 +01:00
CI
3ce742eb01 chore: [skip ci] 2025-11-30 22:26:08 +00:00
CI
ae566fb907 chore: release version v1.89.3 2025-11-30 22:26:08 +00:00
Dmitry Popov
fa32c62f63 Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-11-30 23:25:48 +01:00
Dmitry Popov
6880be11c5 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-30 23:25:40 +01:00
Dmitry Popov
5289893264 fix(core): fixed tracking issues 2025-11-30 23:25:37 +01:00
CI
f15370a3df chore: [skip ci] 2025-11-30 18:07:05 +00:00
Dmitry Popov
2cb2dc526c Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-11-30 18:51:58 +01:00
Dmitry Popov
c3de3c4e35 Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-11-29 20:17:14 +01:00
Dmitry Popov
4585c3a94b feat(core): Added several map scopes support (Wh, Hi, Low, Null, Pochven) 2025-11-29 14:36:45 +01:00
Dmitry Popov
46a1898be9 Merge branch 'fixed-warinings' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-11-29 12:35:36 +01:00
Dmitry Popov
e7219e0eec chore: fixed compile warnings 2025-11-29 12:34:28 +01:00
118 changed files with 4019 additions and 595 deletions

View File

@@ -2,6 +2,100 @@
<!-- changelog -->
## [v1.90.5](https://github.com/wanderer-industries/wanderer/compare/v1.90.4...v1.90.5) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes
## [v1.90.4](https://github.com/wanderer-industries/wanderer/compare/v1.90.3...v1.90.4) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes & signatures clean up behaviour
## [v1.90.3](https://github.com/wanderer-industries/wanderer/compare/v1.90.2...v1.90.3) (2025-12-11)
### Bug Fixes:
* core: added pagination for long ACL lists
## [v1.90.2](https://github.com/wanderer-industries/wanderer/compare/v1.90.1...v1.90.2) (2025-12-10)
### Bug Fixes:
* core: added system position updates to SSE
## [v1.90.1](https://github.com/wanderer-industries/wanderer/compare/v1.90.0...v1.90.1) (2025-12-08)
### Bug Fixes:
* core: fixed connections and signatures remove issues, added comprehensive audit log for auto removed connections and signatures
## [v1.90.0](https://github.com/wanderer-industries/wanderer/compare/v1.89.6...v1.90.0) (2025-12-06)
### Features:
* core: Added several map scopes support (Wh, Hi, Low, Null, Pochven)
### Bug Fixes:
* core: fixed clean up for linked signatures
* core: fixed issue with default select mode
* apiV1 default fields updates
## [v1.89.6](https://github.com/wanderer-industries/wanderer/compare/v1.89.5...v1.89.6) (2025-12-02)
### Bug Fixes:
* kills: fixed zkb links (added "allow-popups-to-escape-sandbox" to CSP)
## [v1.89.5](https://github.com/wanderer-industries/wanderer/compare/v1.89.4...v1.89.5) (2025-12-02)
## [v1.89.4](https://github.com/wanderer-industries/wanderer/compare/v1.89.3...v1.89.4) (2025-12-02)
### Bug Fixes:
* core: fixed acl character update issues
## [v1.89.3](https://github.com/wanderer-industries/wanderer/compare/v1.89.2...v1.89.3) (2025-11-30)
### Bug Fixes:
* core: fixed tracking issues
## [v1.89.2](https://github.com/wanderer-industries/wanderer/compare/v1.89.1...v1.89.2) (2025-11-30)

View File

@@ -32,6 +32,56 @@ format f:
test t:
MIX_ENV=test mix test
# Run tests in 4 parallel partitions (useful for CI or faster local runs)
test-parallel tp:
@echo "Running tests in 4 parallel partitions..."
@mkdir -p /tmp/wanderer_test_results
@rm -f /tmp/wanderer_test_results/partition_*.txt /tmp/wanderer_test_results/exit_*.txt
@for i in 1 2 3 4; do \
(MIX_TEST_PARTITION=$$i MIX_ENV=test mix test --partitions 4 2>&1; echo $$? > /tmp/wanderer_test_results/exit_$$i.txt) | \
tee /tmp/wanderer_test_results/partition_$$i.txt | sed "s/^/[P$$i] /" & \
done; \
wait
@echo ""
@echo "========================================"
@echo " TEST RESULTS SUMMARY"
@echo "========================================"
@total_tests=0; total_failures=0; total_excluded=0; all_passed=true; \
for i in 1 2 3 4; do \
exit_code=$$(cat /tmp/wanderer_test_results/exit_$$i.txt 2>/dev/null || echo "1"); \
if [ "$$exit_code" != "0" ]; then all_passed=false; fi; \
summary=$$(grep -E "^[0-9]+ (tests?|doctest)" /tmp/wanderer_test_results/partition_$$i.txt | tail -1 || echo "No results"); \
tests=$$(echo "$$summary" | grep -oE "^[0-9]+" || echo "0"); \
failures=$$(echo "$$summary" | grep -oE "[0-9]+ failures?" | grep -oE "^[0-9]+" || echo "0"); \
excluded=$$(echo "$$summary" | grep -oE "[0-9]+ excluded" | grep -oE "^[0-9]+" || echo "0"); \
total_tests=$$((total_tests + tests)); \
total_failures=$$((total_failures + failures)); \
total_excluded=$$((total_excluded + excluded)); \
if [ "$$exit_code" = "0" ]; then \
echo "Partition $$i: ✓ $$summary"; \
else \
echo "Partition $$i: ✗ $$summary (exit code: $$exit_code)"; \
fi; \
done; \
echo "========================================"; \
echo "TOTAL: $$total_tests tests, $$total_failures failures, $$total_excluded excluded"; \
echo "========================================"; \
if [ "$$all_passed" = "true" ]; then \
echo "✓ All partitions passed!"; \
else \
echo "✗ Some partitions failed. Details below:"; \
echo ""; \
for i in 1 2 3 4; do \
exit_code=$$(cat /tmp/wanderer_test_results/exit_$$i.txt 2>/dev/null || echo "1"); \
if [ "$$exit_code" != "0" ]; then \
echo "======== PARTITION $$i FAILURES ========"; \
grep -A 50 "Failures:" /tmp/wanderer_test_results/partition_$$i.txt 2>/dev/null || cat /tmp/wanderer_test_results/partition_$$i.txt; \
echo ""; \
fi; \
done; \
exit 1; \
fi
coverage cover co:
MIX_ENV=test mix test --cover

View File

@@ -264,7 +264,7 @@ config :logger,
case config_env() do
:prod -> "info"
:dev -> "info"
:test -> "debug"
:test -> "warning"
end
)
)

View File

@@ -16,6 +16,11 @@ defmodule WandererApp.Api.AccessList do
includes([:owner, :members])
default_fields([
:name,
:description
])
derive_filter?(true)
derive_sort?(true)
@@ -79,12 +84,15 @@ defmodule WandererApp.Api.AccessList do
attribute :name, :string do
allow_nil? false
public? true
end
attribute :description, :string do
allow_nil? true
public? true
end
# Note: api_key intentionally not public for security
attribute :api_key, :string do
allow_nil? true
end

View File

@@ -16,6 +16,14 @@ defmodule WandererApp.Api.AccessListMember do
includes([:access_list])
default_fields([
:name,
:eve_character_id,
:eve_corporation_id,
:eve_alliance_id,
:role
])
derive_filter?(true)
derive_sort?(true)
@@ -89,22 +97,27 @@ defmodule WandererApp.Api.AccessListMember do
attribute :name, :string do
allow_nil? false
public? true
end
attribute :eve_character_id, :string do
allow_nil? true
public? true
end
attribute :eve_corporation_id, :string do
allow_nil? true
public? true
end
attribute :eve_alliance_id, :string do
allow_nil? true
public? true
end
attribute :role, :atom do
default "viewer"
public? true
constraints(
one_of: [

View File

@@ -19,9 +19,10 @@ defmodule WandererApp.Api.Changes.InjectMapFromActor do
_other ->
# nil or unexpected return shape - check for direct map_id
# Check params (input), arguments, and attributes (in that order)
map_id = Map.get(changeset.params, :map_id) ||
Ash.Changeset.get_argument(changeset, :map_id) ||
Ash.Changeset.get_attribute(changeset, :map_id)
map_id =
Map.get(changeset.params, :map_id) ||
Ash.Changeset.get_argument(changeset, :map_id) ||
Ash.Changeset.get_attribute(changeset, :map_id)
case map_id do
nil ->

View File

@@ -13,6 +13,8 @@ defmodule WandererApp.Api.Map do
postgres do
repo(WandererApp.Repo)
table("maps_v1")
migration_defaults scopes: "'{wormholes}'"
end
json_api do
@@ -111,6 +113,7 @@ defmodule WandererApp.Api.Map do
:slug,
:description,
:scope,
:scopes,
:only_tracked_characters,
:owner_id,
:sse_enabled
@@ -135,6 +138,7 @@ defmodule WandererApp.Api.Map do
:slug,
:description,
:scope,
:scopes,
:only_tracked_characters,
:owner_id,
:sse_enabled
@@ -209,7 +213,7 @@ defmodule WandererApp.Api.Map do
end
create :duplicate do
accept [:name, :description, :scope, :only_tracked_characters]
accept [:name, :description, :scope, :scopes, :only_tracked_characters]
argument :source_map_id, :uuid, allow_nil?: false
argument :copy_acls, :boolean, default: true
argument :copy_user_settings, :boolean, default: true
@@ -225,9 +229,14 @@ defmodule WandererApp.Api.Map do
description =
Ash.Changeset.get_attribute(changeset, :description) || source_map.description
# Use provided scopes or fall back to source map scopes
scopes =
Ash.Changeset.get_attribute(changeset, :scopes) || source_map.scopes
changeset
|> Ash.Changeset.change_attribute(:description, description)
|> Ash.Changeset.change_attribute(:scope, source_map.scope)
|> Ash.Changeset.change_attribute(:scopes, scopes)
|> Ash.Changeset.change_attribute(
:only_tracked_characters,
source_map.only_tracked_characters
@@ -359,6 +368,24 @@ defmodule WandererApp.Api.Map do
public?(true)
end
attribute :scopes, {:array, :atom} do
default([:wormholes])
allow_nil?(true)
public?(true)
constraints(
items: [
one_of: [
:wormholes,
:hi,
:low,
:null,
:pochven
]
]
)
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
end

View File

@@ -27,6 +27,11 @@ defmodule WandererApp.Api.MapCharacterSettings do
includes([:map, :character])
default_fields([
:tracked,
:followed
])
derive_filter?(true)
derive_sort?(true)
@@ -219,14 +224,17 @@ defmodule WandererApp.Api.MapCharacterSettings do
attribute :tracked, :boolean do
default false
public? true
allow_nil? true
end
attribute :followed, :boolean do
default false
public? true
allow_nil? true
end
# Note: These attributes are encrypted (AshCloak) and intentionally not public
attribute :solar_system_id, :integer
attribute :structure_id, :integer
attribute :station_id, :integer

View File

@@ -22,6 +22,19 @@ defmodule WandererApp.Api.MapConnection do
includes([:map])
default_fields([
:solar_system_source,
:solar_system_target,
:mass_status,
:time_status,
:ship_size_type,
:type,
:wormhole_type,
:count_of_passage,
:locked,
:custom_info
])
derive_filter?(true)
derive_sort?(true)
@@ -197,15 +210,20 @@ defmodule WandererApp.Api.MapConnection do
attributes do
uuid_primary_key :id
attribute :solar_system_source, :integer
attribute :solar_system_target, :integer
attribute :solar_system_source, :integer do
public? true
end
attribute :solar_system_target, :integer do
public? true
end
# where 0 - greater than half
# where 1 - less than half
# where 2 - critical less than 10%
attribute :mass_status, :integer do
default(0)
public? true
allow_nil?(true)
end
@@ -218,7 +236,7 @@ defmodule WandererApp.Api.MapConnection do
# 6 - EOL 48h
attribute :time_status, :integer do
default(0)
public? true
allow_nil?(true)
end
@@ -229,7 +247,7 @@ defmodule WandererApp.Api.MapConnection do
# where 4 - Capital
attribute :ship_size_type, :integer do
default(2)
public? true
allow_nil?(true)
end
@@ -238,21 +256,26 @@ defmodule WandererApp.Api.MapConnection do
# where 2 - Bridge
attribute :type, :integer do
default(0)
public? true
allow_nil?(true)
end
attribute :wormhole_type, :string
attribute :wormhole_type, :string do
public? true
end
attribute :count_of_passage, :integer do
default(0)
public? true
allow_nil?(true)
end
attribute :locked, :boolean
attribute :locked, :boolean do
public? true
end
attribute :custom_info, :string do
public? true
allow_nil? true
end

View File

@@ -23,6 +23,10 @@ defmodule WandererApp.Api.MapDefaultSettings do
:updated_by
])
default_fields([
:settings
])
routes do
base("/map_default_settings")
@@ -93,6 +97,7 @@ defmodule WandererApp.Api.MapDefaultSettings do
attribute :settings, :string do
allow_nil? false
public? true
constraints min_length: 2
description "JSON string containing the default map settings"
end

View File

@@ -18,6 +18,15 @@ defmodule WandererApp.Api.MapSubscription do
:map
])
default_fields([
:plan,
:status,
:characters_limit,
:hubs_limit,
:active_till,
:auto_renew?
])
# Enable automatic filtering and sorting
derive_filter?(true)
derive_sort?(true)
@@ -135,6 +144,7 @@ defmodule WandererApp.Api.MapSubscription do
attribute :plan, :atom do
default "alpha"
public? true
constraints(
one_of: [
@@ -150,6 +160,7 @@ defmodule WandererApp.Api.MapSubscription do
attribute :status, :atom do
default "active"
public? true
constraints(
one_of: [
@@ -164,22 +175,24 @@ defmodule WandererApp.Api.MapSubscription do
attribute :characters_limit, :integer do
default(100)
public? true
allow_nil?(true)
end
attribute :hubs_limit, :integer do
default(10)
public? true
allow_nil?(true)
end
attribute :active_till, :utc_datetime do
allow_nil? true
public? true
end
attribute :auto_renew?, :boolean do
allow_nil? false
public? true
end
create_timestamp(:inserted_at)

View File

@@ -19,6 +19,10 @@ defmodule WandererApp.Api.MapSystemComment do
:character
])
default_fields([
:text
])
routes do
base("/map_system_comments")
@@ -73,6 +77,7 @@ defmodule WandererApp.Api.MapSystemComment do
attribute :text, :string do
allow_nil? false
public? true
end
create_timestamp(:inserted_at)

View File

@@ -16,6 +16,20 @@ defmodule WandererApp.Api.MapSystemSignature do
includes([:system])
default_fields([
:eve_id,
:character_eve_id,
:name,
:description,
:temporary_name,
:type,
:linked_system_id,
:kind,
:group,
:custom_info,
:deleted
])
derive_filter?(true)
derive_sort?(true)
@@ -184,42 +198,56 @@ defmodule WandererApp.Api.MapSystemSignature do
attribute :eve_id, :string do
allow_nil? false
public? true
end
attribute :character_eve_id, :string do
allow_nil? false
public? true
end
attribute :name, :string do
allow_nil? true
public? true
end
attribute :description, :string do
allow_nil? true
public? true
end
attribute :temporary_name, :string do
allow_nil? true
public? true
end
attribute :type, :string do
allow_nil? true
public? true
end
attribute :linked_system_id, :integer do
allow_nil? true
public? true
end
attribute :kind, :string
attribute :group, :string
attribute :kind, :string do
public? true
end
attribute :group, :string do
public? true
end
attribute :custom_info, :string do
allow_nil? true
public? true
end
attribute :deleted, :boolean do
allow_nil? false
default false
public? true
end
attribute :update_forced_at, :utc_datetime do

View File

@@ -41,6 +41,21 @@ defmodule WandererApp.Api.MapSystemStructure do
:system
])
default_fields([
:structure_type_id,
:structure_type,
:character_eve_id,
:solar_system_name,
:solar_system_id,
:name,
:notes,
:owner_name,
:owner_ticker,
:owner_id,
:status,
:end_time
])
# Enable automatic filtering and sorting
derive_filter?(true)
derive_sort?(true)
@@ -151,50 +166,62 @@ defmodule WandererApp.Api.MapSystemStructure do
attribute :structure_type_id, :string do
allow_nil? false
public? true
end
attribute :structure_type, :string do
allow_nil? false
public? true
end
attribute :character_eve_id, :string do
allow_nil? false
public? true
end
attribute :solar_system_name, :string do
allow_nil? false
public? true
end
attribute :solar_system_id, :integer do
allow_nil? false
public? true
end
attribute :name, :string do
allow_nil? false
public? true
end
attribute :notes, :string do
allow_nil? true
public? true
end
attribute :owner_name, :string do
allow_nil? true
public? true
end
attribute :owner_ticker, :string do
allow_nil? true
public? true
end
attribute :owner_id, :string do
allow_nil? true
public? true
end
attribute :status, :string do
allow_nil? true
public? true
end
attribute :end_time, :utc_datetime_usec do
allow_nil? true
public? true
end
create_timestamp :inserted_at

View File

@@ -24,6 +24,13 @@ defmodule WandererApp.Api.MapUserSettings do
:user
])
default_fields([
:settings,
:main_character_eve_id,
:following_character_eve_id,
:hubs
])
routes do
base("/map_user_settings")
@@ -85,19 +92,22 @@ defmodule WandererApp.Api.MapUserSettings do
attribute :settings, :string do
allow_nil? true
public? true
end
attribute :main_character_eve_id, :string do
allow_nil? true
public? true
end
attribute :following_character_eve_id, :string do
allow_nil? true
public? true
end
attribute :hubs, {:array, :string} do
allow_nil?(true)
public? true
default([])
end
end

View File

@@ -31,6 +31,13 @@ defmodule WandererApp.Api.UserActivity do
includes([:character, :user])
default_fields([
:entity_id,
:entity_type,
:event_type,
:event_data
])
derive_filter?(true)
derive_sort?(true)
@@ -86,10 +93,12 @@ defmodule WandererApp.Api.UserActivity do
attribute :entity_id, :string do
allow_nil? false
public? true
end
attribute :entity_type, :atom do
default "map"
public? true
constraints(
one_of: [
@@ -104,6 +113,7 @@ defmodule WandererApp.Api.UserActivity do
attribute :event_type, :atom do
default "custom"
public? true
constraints(
one_of: [
@@ -153,7 +163,9 @@ defmodule WandererApp.Api.UserActivity do
allow_nil?(false)
end
attribute :event_data, :string
attribute :event_data, :string do
public? true
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)

View File

@@ -45,7 +45,7 @@ defmodule WandererApp.Cache do
def insert({id, key}, value, opts) when is_binary(id) and (is_binary(key) or is_atom(key)),
do: insert("#{id}:#{key}", value, opts)
def insert(key, nil, opts) when is_binary(key) or is_atom(key), do: delete(key)
def insert(key, nil, _opts) when is_binary(key) or is_atom(key), do: delete(key)
def insert(key, value, opts) when is_binary(key) or is_atom(key), do: put(key, value, opts)
def insert_or_update(key, value, update_fn, opts \\ [])

View File

@@ -598,9 +598,6 @@ defmodule WandererApp.Character.Tracker do
{:error, :skipped}
end
_ ->
{:error, :skipped}
end
_ ->
@@ -799,7 +796,7 @@ defmodule WandererApp.Character.Tracker do
corporation_id
|> WandererApp.Esi.get_corporation_info()
|> case do
{:ok, %{"name" => corporation_name, "ticker" => corporation_ticker} = corporation_info} ->
{:ok, %{"name" => corporation_name, "ticker" => corporation_ticker}} ->
{:ok, character} =
WandererApp.Character.get_character(character_id)
@@ -1002,7 +999,7 @@ defmodule WandererApp.Character.Tracker do
defp maybe_update_active_maps(
%{character_id: character_id, active_maps: active_maps} =
state,
%{map_id: map_id, track: true} = track_settings
%{map_id: map_id, track: true}
) do
if not Enum.member?(active_maps, map_id) do
WandererApp.Cache.put(

View File

@@ -40,10 +40,12 @@ defmodule WandererApp.Character.TrackerManager.Impl do
Process.send_after(self(), :garbage_collect, @garbage_collection_interval)
Process.send_after(self(), :untrack_characters, @untrack_characters_interval)
Logger.debug("[TrackerManager] Initialized with intervals: " <>
"garbage_collection=#{div(@garbage_collection_interval, 60_000)}min, " <>
"untrack=#{div(@untrack_characters_interval, 60_000)}min, " <>
"inactive_timeout=#{div(@inactive_character_timeout, 60_000)}min")
Logger.debug(
"[TrackerManager] Initialized with intervals: " <>
"garbage_collection=#{div(@garbage_collection_interval, 60_000)}min, " <>
"untrack=#{div(@untrack_characters_interval, 60_000)}min, " <>
"inactive_timeout=#{div(@inactive_character_timeout, 60_000)}min"
)
%{
characters: [],
@@ -57,7 +59,9 @@ defmodule WandererApp.Character.TrackerManager.Impl do
WandererApp.Cache.insert("tracked_characters", [])
if length(tracked_characters) > 0 do
Logger.debug("[TrackerManager] Restoring #{length(tracked_characters)} tracked characters from cache")
Logger.debug(
"[TrackerManager] Restoring #{length(tracked_characters)} tracked characters from cache"
)
end
tracked_characters
@@ -197,6 +201,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
[],
fn untrack_queue ->
original_length = length(untrack_queue)
filtered =
untrack_queue
|> Enum.reject(fn {m_id, c_id} -> m_id == map_id and c_id == character_id end)

View File

@@ -88,15 +88,4 @@ defmodule WandererApp.Character.TrackerPoolDynamicSupervisor do
{:ok, pid}
end
end
defp stop_child(uuid) do
case Registry.lookup(@registry, uuid) do
[{pid, _}] ->
GenServer.cast(pid, :stop)
_ ->
Logger.warn("Unable to locate pool assigned to #{inspect(uuid)}")
:ok
end
end
end

View File

@@ -38,7 +38,7 @@ defmodule WandererApp.Character.TrackingConfigUtils do
%{id: "default", title: "Default", value: default_count}
]
{:ok, pools_count} =
{:ok, _pools_count} =
Cachex.get(
:esi_auth_cache,
"configs_total_count"

View File

@@ -56,13 +56,7 @@ defmodule WandererApp.Character.TrackingUtils do
Only includes characters that have actual tracking permission.
"""
def build_tracking_data(map_id, current_user_id) do
with {:ok, map} <-
WandererApp.MapRepo.get(map_id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
),
with {:ok, map} <- WandererApp.MapRepo.get(map_id),
{:ok, user_settings} <- WandererApp.MapUserSettingsRepo.get(map_id, current_user_id),
{:ok, %{characters: characters_with_access}} <-
WandererApp.Maps.load_characters(map, current_user_id) do
@@ -75,7 +69,11 @@ defmodule WandererApp.Character.TrackingUtils do
build_character_tracking_data(characters_with_tracking_permission)
{:ok, main_character} =
get_main_character(user_settings, characters_with_tracking_permission, characters_with_tracking_permission)
get_main_character(
user_settings,
characters_with_tracking_permission,
characters_with_tracking_permission
)
following_character_eve_id =
case user_settings do
@@ -195,7 +193,13 @@ defmodule WandererApp.Character.TrackingUtils do
{true, settings_result} ->
case check_character_tracking_permission(character, map_id) do
{:ok, :allowed} ->
do_update_character_tracking_impl(character, map_id, track, caller_pid, settings_result)
do_update_character_tracking_impl(
character,
map_id,
track,
caller_pid,
settings_result
)
{:error, reason} ->
Logger.warning(

View File

@@ -8,7 +8,6 @@ defmodule WandererApp.Esi.ApiClient do
@ttl :timer.hours(1)
@wanderrer_user_agent "(wanderer-industries@proton.me; +https://github.com/wanderer-industries/wanderer)"
@req_esi_options [base_url: "https://esi.evetech.net", finch: WandererApp.Finch]
@cache_opts [cache: true]
@retry_opts [retry: false, retry_log_level: :warning]
@@ -74,7 +73,7 @@ defmodule WandererApp.Esi.ApiClient do
|> Keyword.merge(@timeout_opts)
)
def get_routes_eve(hubs, origin, params, opts),
def get_routes_eve(hubs, origin, _params, _opts),
do:
{:ok,
hubs
@@ -101,33 +100,6 @@ defmodule WandererApp.Esi.ApiClient do
end
end)}
defp do_get_routes_eve(origin, destination, params, opts) do
esi_params =
Map.merge(params, %{
connections: params.connections |> Enum.join(","),
avoid: params.avoid |> Enum.join(",")
})
do_get(
"/route/#{origin}/#{destination}/?#{esi_params |> Plug.Conn.Query.encode()}",
opts,
@cache_opts
)
|> case do
{:ok, result} ->
%{
"origin" => origin,
"destination" => destination,
"systems" => result,
"success" => true
}
error ->
Logger.warning("Error getting routes: #{inspect(error)}")
%{"origin" => origin, "destination" => destination, "systems" => [], "success" => false}
end
end
@decorate cacheable(
cache: Cache,
key: "group-info-#{group_id}",
@@ -273,6 +245,8 @@ defmodule WandererApp.Esi.ApiClient do
opts: [ttl: @ttl]
)
defp get_search(character_eve_id, search_val, categories_val, merged_opts) do
# Note: search_val and categories_val are used by the @decorate cacheable annotation above
_unused = {search_val, categories_val}
get_character_auth_data(character_eve_id, "search", merged_opts)
end
@@ -348,7 +322,7 @@ defmodule WandererApp.Esi.ApiClient do
defp with_cache_opts(opts),
do: opts |> Keyword.merge(@cache_opts) |> Keyword.merge(cache_dir: System.tmp_dir!())
defp do_get(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
defp do_get(path, api_opts, opts, pool \\ @general_pool) do
case Cachex.get(:api_cache, path) do
{:ok, cached_data} when not is_nil(cached_data) ->
{:ok, cached_data}
@@ -358,7 +332,7 @@ defmodule WandererApp.Esi.ApiClient do
end
end
defp do_get_request(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
defp do_get_request(path, api_opts, opts, pool) do
try do
req_options_for_pool(pool)
|> Req.new()
@@ -448,7 +422,7 @@ defmodule WandererApp.Esi.ApiClient do
{:ok, %{status: status} = _error} when status in [401, 403] ->
do_get_retry(path, api_opts, opts)
{:ok, %{status: status, headers: headers}} ->
{:ok, %{status: status}} ->
{:error, "Unexpected status: #{status}"}
{:error, %Mint.TransportError{reason: :timeout}} ->
@@ -832,10 +806,10 @@ defmodule WandererApp.Esi.ApiClient do
defp handle_refresh_token_result(
{:error, %OAuth2.Error{reason: :econnrefused} = error},
character,
_character,
character_id,
expires_at,
scopes
_scopes
) do
expires_at_datetime = DateTime.from_unix!(expires_at)
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)

View File

@@ -393,9 +393,6 @@ defmodule WandererApp.EveDataService do
end
end
defp get_solar_system_name(solar_system_name, wormhole_class) do
end
defp get_triglavian_data(default_data, triglavian_systems, solar_system_id) do
case Enum.find(triglavian_systems, fn system -> system.solar_system_id == solar_system_id end) do
nil ->
@@ -413,8 +410,12 @@ defmodule WandererApp.EveDataService do
defp get_security(security) do
case security do
nil -> {:ok, ""}
_ -> {:ok, String.to_float(security) |> get_true_security() |> Float.to_string(decimals: 1)}
nil ->
{:ok, ""}
_ ->
{:ok,
String.to_float(security) |> get_true_security() |> :erlang.float_to_binary(decimals: 1)}
end
end
@@ -496,23 +497,23 @@ defmodule WandererApp.EveDataService do
do: {:ok, 10_100}
defp get_wormhole_class_id(systems, region_id, constellation_id, solar_system_id) do
with region <-
Enum.find(systems, fn system ->
system.location_id |> Integer.parse() |> elem(0) == region_id
end),
constellation <-
Enum.find(systems, fn system ->
system.location_id |> Integer.parse() |> elem(0) == constellation_id
end),
solar_system <-
Enum.find(systems, fn system ->
system.location_id |> Integer.parse() |> elem(0) == solar_system_id
end),
wormhole_class_id <- get_wormhole_class_id(region, constellation, solar_system) do
{:ok, wormhole_class_id}
else
_ -> {:ok, -1}
end
region =
Enum.find(systems, fn system ->
system.location_id |> Integer.parse() |> elem(0) == region_id
end)
constellation =
Enum.find(systems, fn system ->
system.location_id |> Integer.parse() |> elem(0) == constellation_id
end)
solar_system =
Enum.find(systems, fn system ->
system.location_id |> Integer.parse() |> elem(0) == solar_system_id
end)
wormhole_class_id = get_wormhole_class_id(region, constellation, solar_system)
{:ok, wormhole_class_id}
end
defp get_wormhole_class_id(_region, _constellation, solar_system)

View File

@@ -178,6 +178,10 @@ defmodule WandererApp.ExternalEvents.Event do
end
end
defp serialize_payload(payload, visited) when is_map(payload) do
Map.new(payload, fn {k, v} -> {to_string(k), serialize_value(v, visited)} end)
end
# Get allowed fields based on struct type
defp get_allowed_fields(module) do
module_name = module |> Module.split() |> List.last()
@@ -192,10 +196,6 @@ defmodule WandererApp.ExternalEvents.Event do
end
end
defp serialize_payload(payload, visited) when is_map(payload) do
Map.new(payload, fn {k, v} -> {to_string(k), serialize_value(v, visited)} end)
end
defp serialize_fields(fields, visited) do
Enum.reduce(fields, %{}, fn {k, v}, acc ->
if is_nil(v) do

View File

@@ -98,8 +98,8 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
"id" => payload["system_id"] || payload[:system_id],
"attributes" => %{
"locked" => payload["locked"] || payload[:locked],
"x" => payload["x"] || payload[:x],
"y" => payload["y"] || payload[:y],
"position_x" => payload["position_x"] || payload[:position_x],
"position_y" => payload["position_y"] || payload[:position_y],
"updated_at" => event.timestamp
},
"relationships" => %{

View File

@@ -182,7 +182,7 @@ defmodule WandererApp.Kills.Client do
end
# Guard against duplicate disconnection events
def handle_info({:disconnected, reason}, %{connected: false, connecting: false} = state) do
def handle_info({:disconnected, _reason}, %{connected: false, connecting: false} = state) do
{:noreply, state}
end
@@ -566,7 +566,7 @@ defmodule WandererApp.Kills.Client do
end
end
defp check_health(%{socket_pid: pid} = state) do
defp check_health(%{socket_pid: pid}) do
if socket_alive?(pid) do
:healthy
else
@@ -590,22 +590,6 @@ defmodule WandererApp.Kills.Client do
Process.send_after(self(), :health_check, @health_check_interval)
end
defp handle_connection_lost(%{connected: false} = _state) do
Logger.debug("[Client] Connection already lost, skipping cleanup")
end
defp handle_connection_lost(state) do
Logger.warning("[Client] Connection lost, cleaning up and reconnecting")
# Clean up existing socket
if state.socket_pid do
disconnect_socket(state.socket_pid)
end
# Reset state and trigger reconnection
send(self(), {:disconnected, :connection_lost})
end
# Handler module for WebSocket events
defmodule Handler do
@moduledoc """
@@ -640,7 +624,7 @@ defmodule WandererApp.Kills.Client do
}
case GenSocketClient.join(transport, "killmails:lobby", join_params) do
{:ok, response} ->
{:ok, _response} ->
send(state.parent, {:connected, self()})
# Reset disconnected flag on successful connection
{:ok, %{state | disconnected: false}}

View File

@@ -46,7 +46,7 @@ defmodule WandererApp.Kills.MapEventListener do
end
@impl true
def handle_info(%{event: :map_server_started, payload: map_info}, state) do
def handle_info(%{event: :map_server_started, payload: _map_info}, state) do
{:noreply, schedule_subscription_update(state)}
end
@@ -191,7 +191,7 @@ defmodule WandererApp.Kills.MapEventListener do
# Client is not connected, retry with backoff
schedule_retry_update(state)
error ->
_error ->
schedule_retry_update(state)
end
rescue

View File

@@ -177,7 +177,7 @@ defmodule WandererApp.Map do
end
def list_hubs(map_id, hubs) do
{:ok, map} = map_id |> get_map()
{:ok, _map} = map_id |> get_map()
{:ok, hubs}
end
@@ -315,7 +315,7 @@ defmodule WandererApp.Map do
end
end
def update_subscription_settings!(%{map_id: map_id} = map, %{
def update_subscription_settings!(%{map_id: map_id} = _map, %{
characters_limit: characters_limit,
hubs_limit: hubs_limit
}) do
@@ -326,7 +326,7 @@ defmodule WandererApp.Map do
|> get_map!()
end
def update_options!(%{map_id: map_id} = map, options) do
def update_options!(%{map_id: map_id} = _map, options) do
map_id
|> update_map(%{options: options})

View File

@@ -76,11 +76,6 @@ defmodule WandererApp.Map.Operations do
{:ok, map()} | {:skip, :exists} | {:error, String.t()}
defdelegate create_connection(map_id, attrs, char_id), to: Connections
@doc "Create a connection from a Plug.Conn"
@spec create_connection(Plug.Conn.t(), map()) ::
{:ok, :created} | {:skip, :exists} | {:error, atom()}
defdelegate create_connection(conn, attrs), to: Connections
@doc "Update a connection"
@spec update_connection(String.t(), String.t(), map()) ::
{:ok, map()} | {:error, String.t()}

View File

@@ -329,6 +329,9 @@ defmodule WandererApp.Map.MapPool do
end
end
@impl true
def handle_call(:error, _, state), do: {:stop, :error, :ok, state}
defp do_start_map(map_id, %{map_ids: map_ids, uuid: uuid} = state) do
if map_id in map_ids do
# Map already started
@@ -344,8 +347,6 @@ defmodule WandererApp.Map.MapPool do
[map_id | r_map_ids]
end)
completed_operations = [:registry | completed_operations]
case registry_result do
{new_value, _old_value} when is_list(new_value) ->
:ok
@@ -363,13 +364,9 @@ defmodule WandererApp.Map.MapPool do
raise "Failed to add to cache: #{inspect(reason)}"
end
completed_operations = [:cache | completed_operations]
# Step 3: Start the map server using extracted helper
do_initialize_map_server(map_id)
completed_operations = [:map_server | completed_operations]
# Step 4: Update GenServer state (last, as this is in-memory and fast)
new_state = %{state | map_ids: [map_id | map_ids]}
@@ -445,8 +442,6 @@ defmodule WandererApp.Map.MapPool do
r_map_ids |> Enum.reject(fn id -> id == map_id end)
end)
completed_operations = [:registry | completed_operations]
case registry_result do
{new_value, _old_value} when is_list(new_value) ->
:ok
@@ -464,14 +459,10 @@ defmodule WandererApp.Map.MapPool do
raise "Failed to delete from cache: #{inspect(reason)}"
end
completed_operations = [:cache | completed_operations]
# Step 3: Stop the map server (clean up all map resources)
map_id
|> Server.Impl.stop_map()
completed_operations = [:map_server | completed_operations]
# Step 4: Update GenServer state (last, as this is in-memory and fast)
new_state = %{state | map_ids: map_ids |> Enum.reject(fn id -> id == map_id end)}
@@ -560,9 +551,6 @@ defmodule WandererApp.Map.MapPool do
# and the cleanup operations are safe to leave in a "stopped" state
end
@impl true
def handle_call(:error, _, state), do: {:stop, :error, :ok, state}
@impl true
def handle_info(:backup_state, %{map_ids: map_ids, uuid: uuid} = state) do
Process.send_after(self(), :backup_state, @backup_state_timeout)

View File

@@ -179,15 +179,4 @@ defmodule WandererApp.Map.MapPoolDynamicSupervisor do
{:ok, pid}
end
end
defp stop_child(uuid) do
case Registry.lookup(@registry, uuid) do
[{pid, _}] ->
GenServer.cast(pid, :stop)
_ ->
Logger.warn("Unable to locate pool assigned to #{inspect(uuid)}")
:ok
end
end
end

View File

@@ -77,7 +77,7 @@ defmodule WandererApp.Map.Routes do
end
end
def find(_map_id, hubs, origin, routes_settings, true) do
def find(_map_id, hubs, origin, _routes_settings, true) do
origin = origin |> String.to_integer()
hubs = hubs |> Enum.map(&(&1 |> String.to_integer()))

View File

@@ -93,10 +93,8 @@ defmodule WandererApp.Map.Operations.Connections do
end
end
@doc """
Determines the ship size for a connection, applying wormholespecific rules
for C1, C13, and C4⇄NS links, falling back to the callers provided size or Large.
"""
# Determines the ship size for a connection, applying wormhole-specific rules
# for C1, C13, and C4⇄NS links, falling back to the caller's provided size or Large.
defp resolve_ship_size(type_val, ship_size_val, src_info, tgt_info) do
case parse_type(type_val) do
@connection_type_wormhole ->

View File

@@ -12,7 +12,6 @@ defmodule WandererApp.Map.Operations.Duplication do
"""
require Logger
import Ash.Query, only: [filter: 2]
alias WandererApp.Api
alias WandererApp.Api.{MapSystem, MapConnection, MapSystemSignature, MapCharacterSettings}

View File

@@ -814,21 +814,24 @@ defmodule WandererApp.Map.Server.CharactersImpl do
do: :ok
defp update_location(
%{map: %{scope: scope}, map_id: map_id, map_opts: map_opts} =
%{map: map, map_id: map_id, map_opts: map_opts} =
_state,
character_id,
location,
old_location
) do
scopes = get_effective_scopes(map)
ConnectionsImpl.is_connection_valid(
scope,
scopes,
old_location.solar_system_id,
location.solar_system_id
)
|> case do
true ->
# Add new location system
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts) do
# Connection is valid (at least one system matches scopes)
# Add systems that match the map's scopes - individual system filtering by maybe_add_system
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts, scopes) do
:ok ->
:ok
@@ -838,8 +841,8 @@ defmodule WandererApp.Map.Server.CharactersImpl do
)
end
# Add old location system (in case it wasn't on map)
case SystemsImpl.maybe_add_system(map_id, old_location, location, map_opts) do
# Add old location system (in case it wasn't on map) - only if it matches scopes
case SystemsImpl.maybe_add_system(map_id, old_location, location, map_opts, scopes) do
:ok ->
:ok
@@ -879,6 +882,24 @@ defmodule WandererApp.Map.Server.CharactersImpl do
defp is_character_in_space?(%{station_id: station_id, structure_id: structure_id} = _location),
do: is_nil(structure_id) && is_nil(station_id)
@doc """
Get effective scopes from map, with fallback to legacy scope.
Returns the scopes array that should be used for filtering.
"""
def get_effective_scopes(%{scopes: scopes}) when is_list(scopes) and scopes != [], do: scopes
def get_effective_scopes(%{scope: scope}) when is_atom(scope),
do: legacy_scope_to_scopes(scope)
def get_effective_scopes(_), do: [:wormholes]
# Legacy scope to new scopes array conversion
defp legacy_scope_to_scopes(:wormholes), do: [:wormholes]
defp legacy_scope_to_scopes(:stargates), do: [:hi, :low, :null, :pochven]
defp legacy_scope_to_scopes(:all), do: [:wormholes, :hi, :low, :null, :pochven]
defp legacy_scope_to_scopes(:none), do: []
defp legacy_scope_to_scopes(_), do: [:wormholes]
defp add_character(
map_id,
%{id: character_id} = map_character,

View File

@@ -57,6 +57,12 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
@known_space [@hs, @ls, @ns, @pochven]
# Individual space type lists for granular scope matching
@hi_space [@hs]
@low_space [@ls]
@null_space [@ns]
@pochven_space [@pochven]
@prohibited_systems [@jita]
@prohibited_system_classes [
@a1,
@@ -100,7 +106,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
@connection_type_wormhole 0
@connection_type_stargate 1
@connection_type_bridge 2
# @connection_type_bridge 2 # reserved for future use
@medium_ship_size 1
def get_connection_auto_expire_hours(), do: WandererApp.Env.map_connection_auto_expire_hours()
@@ -343,6 +349,27 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
solar_system_source: solar_system_source_id,
solar_system_target: solar_system_target_id
} ->
# Emit telemetry for connection auto-deletion
:telemetry.execute(
[:wanderer_app, :map, :connection_cleanup, :delete],
%{system_time: System.system_time()},
%{
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id,
reason: :auto_cleanup
}
)
# Log auto-deletion for audit trail (no user/character context for auto-cleanup)
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
})
delete_connection(map_id, %{
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
@@ -403,7 +430,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
time_status: time_status,
solar_system_source: solar_system_source,
solar_system_target: solar_system_target
} = updated_connection
} = _updated_connection
) do
with source_system when not is_nil(source_system) <-
WandererApp.Map.find_system_by_location(
@@ -644,31 +671,49 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
start_time
)
def can_add_location(_scope, nil), do: false
def can_add_location(_scopes, nil), do: false
def can_add_location(:none, _solar_system_id), do: false
def can_add_location([], _solar_system_id), do: false
def can_add_location(scope, solar_system_id) do
def can_add_location(scopes, solar_system_id) when is_list(scopes) do
{:ok, system_static_info} = get_system_static_info(solar_system_id)
case scope do
:wormholes ->
not is_prohibited_system_class?(system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(solar_system_id)) and
@wh_space |> Enum.member?(system_static_info.system_class)
:stargates ->
not is_prohibited_system_class?(system_static_info.system_class) and
@known_space |> Enum.member?(system_static_info.system_class)
:all ->
not is_prohibited_system_class?(system_static_info.system_class)
_ ->
false
end
not is_prohibited_system_class?(system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(solar_system_id)) and
system_matches_any_scope?(system_static_info.system_class, scopes)
end
# Legacy support for single scope atom
def can_add_location(:none, _solar_system_id), do: false
def can_add_location(scope, solar_system_id) when is_atom(scope) do
can_add_location(legacy_scope_to_scopes(scope), solar_system_id)
end
# Helper function to check if a system class matches any of the selected scopes
defp system_matches_any_scope?(_system_class, []), do: false
defp system_matches_any_scope?(system_class, scopes) do
Enum.any?(scopes, fn scope ->
system_matches_scope?(system_class, scope)
end)
end
# Individual scope matching functions
defp system_matches_scope?(system_class, :wormholes), do: system_class in @wh_space
defp system_matches_scope?(system_class, :hi), do: system_class in @hi_space
defp system_matches_scope?(system_class, :low), do: system_class in @low_space
defp system_matches_scope?(system_class, :null), do: system_class in @null_space
defp system_matches_scope?(system_class, :pochven), do: system_class in @pochven_space
defp system_matches_scope?(_system_class, _), do: false
# Legacy scope to new scopes array conversion
defp legacy_scope_to_scopes(:wormholes), do: [:wormholes]
defp legacy_scope_to_scopes(:stargates), do: [:hi, :low, :null, :pochven]
defp legacy_scope_to_scopes(:all), do: [:wormholes, :hi, :low, :null, :pochven]
defp legacy_scope_to_scopes(:none), do: []
defp legacy_scope_to_scopes(_), do: [:wormholes]
def is_prohibited_system_class?(system_class) do
@prohibited_system_classes |> Enum.member?(system_class)
end
@@ -688,17 +733,59 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
)
)
def is_connection_valid(_scope, from_solar_system_id, to_solar_system_id)
def is_connection_valid(_scopes, from_solar_system_id, to_solar_system_id)
when is_nil(from_solar_system_id) or is_nil(to_solar_system_id),
do: false
def is_connection_valid([], _from_solar_system_id, _to_solar_system_id), do: false
# New array-based scopes support
def is_connection_valid(scopes, from_solar_system_id, to_solar_system_id)
when is_list(scopes) and from_solar_system_id != to_solar_system_id do
with {:ok, from_system_static_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_system_static_info} <- get_system_static_info(to_solar_system_id) do
# First check: neither system is prohibited
not_prohibited =
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id))
if not_prohibited do
from_is_wormhole = from_system_static_info.system_class in @wh_space
to_is_wormhole = to_system_static_info.system_class in @wh_space
wormholes_enabled = :wormholes in scopes
# Wormhole border behavior: if wormholes scope is enabled AND at least one
# system is a wormhole, allow the connection (adds border k-space systems)
# Otherwise: BOTH systems must match the configured scopes
if wormholes_enabled and (from_is_wormhole or to_is_wormhole) do
# At least one system matches (wormhole matches :wormholes, or other matches its scope)
system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes)
else
# Non-wormhole movement: both systems must match scopes
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
end
else
false
end
else
_ -> false
end
end
# Legacy support: :all scope
def is_connection_valid(:all, from_solar_system_id, to_solar_system_id),
do: from_solar_system_id != to_solar_system_id
# Legacy support: :none scope
def is_connection_valid(:none, _from_solar_system_id, _to_solar_system_id), do: false
# Legacy support: single atom scope (including :stargates which is used for connection type detection)
def is_connection_valid(scope, from_solar_system_id, to_solar_system_id)
when from_solar_system_id != to_solar_system_id do
when is_atom(scope) and from_solar_system_id != to_solar_system_id do
with {:ok, known_jumps} <- find_solar_system_jump(from_solar_system_id, to_solar_system_id),
{:ok, from_system_static_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_system_static_info} <- get_system_static_info(to_solar_system_id) do
@@ -712,7 +799,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
:stargates ->
# For stargates, we need to check:
# 1. Both systems are in known space (HS, LS, NS)
# 1. Both systems are in known space (HS, LS, NS, Pochven)
# 2. There is a known jump between them
# 3. Neither system is prohibited
from_system_static_info.system_class in @known_space and
@@ -720,13 +807,21 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (known_jumps |> Enum.empty?())
_ ->
# For other legacy scopes, convert to array and use new logic
is_connection_valid(
legacy_scope_to_scopes(scope),
from_solar_system_id,
to_solar_system_id
)
end
else
_ -> false
end
end
def is_connection_valid(_scope, _from_solar_system_id, _to_solar_system_id), do: false
def is_connection_valid(_scopes, _from_solar_system_id, _to_solar_system_id), do: false
def get_connection_mark_eol_time(map_id, connection_id, default \\ DateTime.utc_now()) do
WandererApp.Cache.get("map_#{map_id}:conn_#{connection_id}:mark_eol_time")
@@ -901,9 +996,6 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
end
end
defp get_time_status(_source_solar_system_id, _target_solar_system_id, _ship_size_type),
do: @connection_time_status_default
defp get_new_time_status(_start_time, @connection_time_status_default),
do: @connection_time_status_eol_24

View File

@@ -156,7 +156,7 @@ defmodule WandererApp.Map.Server.Impl do
Logger.error("Cannot start map #{map_id}: map not loaded")
{:error, :map_not_loaded}
map ->
_map ->
with :ok <- AclsImpl.track_acls(acls |> Enum.map(& &1.access_list_id)) do
@pubsub_client.subscribe(
WandererApp.PubSub,

View File

@@ -5,7 +5,7 @@ defmodule WandererApp.Map.Server.PingsImpl do
alias WandererApp.Map.Server.Impl
@ping_auto_expire_timeout :timer.minutes(15)
# @ping_auto_expire_timeout :timer.minutes(15) # reserved for future use
def add_ping(
map_id,

View File

@@ -129,8 +129,8 @@ defmodule WandererApp.Map.Server.SystemsImpl do
def remove_system_comment(
map_id,
comment_id,
user_id,
character_id
_user_id,
_character_id
) do
{:ok, %{system_id: system_id} = comment} =
WandererApp.MapSystemCommentRepo.get_by_id(comment_id)
@@ -309,7 +309,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
map_id
|> WandererApp.MapSystemRepo.remove_from_map(solar_system_id)
|> case do
{:ok, result} ->
{:ok, _result} ->
:ok = WandererApp.Map.remove_system(map_id, solar_system_id)
@ddrt.delete([solar_system_id], "rtree_#{map_id}")
Impl.broadcast!(map_id, :systems_removed, [solar_system_id])
@@ -383,6 +383,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|> Enum.each(fn connection ->
try do
Logger.debug(fn -> "Removing connection from map: #{inspect(connection)}" end)
# Audit logging for cascade deletion (no user/character context)
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_source_id: connection.solar_system_source,
solar_system_target_id: connection.solar_system_target
})
:ok = WandererApp.MapConnectionRepo.destroy(map_id, connection)
:ok = WandererApp.Map.remove_connection(map_id, connection)
Impl.broadcast!(map_id, :remove_connections, [connection])
@@ -393,35 +403,76 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end)
end
# When destination systems are deleted, unlink signatures instead of destroying them.
# This preserves the user's scan data while removing the stale link.
defp cleanup_linked_signatures(map_id, removed_solar_system_ids) do
removed_solar_system_ids
|> Enum.map(fn solar_system_id ->
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
end)
|> List.flatten()
|> Enum.uniq_by(& &1.system_id)
|> Enum.each(fn s ->
try do
{:ok, %{eve_id: eve_id, system: system}} = s |> Ash.load([:system])
:ok = Ash.destroy!(s)
# Group signatures by their source system for efficient broadcasting
signatures_by_system =
removed_solar_system_ids
|> Enum.flat_map(fn solar_system_id ->
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
end)
|> Enum.uniq_by(& &1.id)
|> Enum.group_by(fn sig -> sig.system_id end)
# Handle case where parent system was already deleted
case system do
nil ->
Logger.warning(
"[cleanup_linked_signatures] signature #{eve_id} destroyed (parent system already deleted)"
)
signatures_by_system
|> Enum.each(fn {_system_id, signatures} ->
signatures
|> Enum.each(fn sig ->
try do
{:ok, %{eve_id: eve_id, system: system}} = sig |> Ash.load([:system])
%{solar_system_id: solar_system_id} ->
Logger.warning(
"[cleanup_linked_signatures] for system #{solar_system_id}: #{inspect(eve_id)}"
)
# Clear the linked_system_id instead of destroying the signature
case WandererApp.Api.MapSystemSignature.update_linked_system(sig, %{
linked_system_id: nil
}) do
{:ok, _updated_sig} ->
case system do
nil ->
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} unlinked (parent system already deleted)"
end)
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
%{solar_system_id: solar_system_id} ->
Logger.debug(fn ->
"[cleanup_linked_signatures] unlinked signature #{eve_id} in system #{solar_system_id}"
end)
# Audit logging for cascade unlink (no user/character context)
WandererApp.User.ActivityTracker.track_map_event(:signatures_unlinked, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_id: solar_system_id,
signatures: [eve_id]
})
end
{:error, error} ->
Logger.error(
"[cleanup_linked_signatures] Failed to unlink signature #{sig.eve_id}: #{inspect(error)}"
)
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
end)
# Broadcast once per source system after all its signatures are processed
case List.first(signatures) do
%{system: %{solar_system_id: solar_system_id}} ->
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
_ ->
# Try to get the system info if not preloaded
case List.first(signatures) |> Ash.load([:system]) do
{:ok, %{system: %{solar_system_id: solar_system_id}}} ->
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
_ ->
:ok
end
end
end)
end
@@ -446,8 +497,32 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end)
end
def maybe_add_system(map_id, location, old_location, map_opts)
def maybe_add_system(map_id, location, old_location, map_opts, scopes \\ nil)
def maybe_add_system(map_id, location, old_location, map_opts, scopes)
when not is_nil(location) do
alias WandererApp.Map.Server.ConnectionsImpl
# Check if the system matches the map's configured scopes before adding
should_add =
case scopes do
nil -> true
[] -> true
scopes when is_list(scopes) ->
ConnectionsImpl.can_add_location(scopes, location.solar_system_id)
end
if should_add do
do_add_system_from_location(map_id, location, old_location, map_opts)
else
# System filtered out by scope settings - this is expected behavior
:ok
end
end
def maybe_add_system(_map_id, _location, _old_location, _map_opts, _scopes), do: :ok
defp do_add_system_from_location(map_id, location, old_location, map_opts) do
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :start],
%{system_time: System.system_time()},
@@ -526,12 +601,14 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|> case do
{:ok, solar_system_info} ->
# Use upsert instead of create - handles race conditions gracefully
# visible: true ensures previously-deleted systems become visible again
WandererApp.MapSystemRepo.upsert(%{
map_id: map_id,
solar_system_id: location.solar_system_id,
name: solar_system_info.solar_system_name,
position_x: position.x,
position_y: position.y
position_y: position.y,
visible: true
})
|> case do
{:ok, system} ->
@@ -653,8 +730,6 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
end
def maybe_add_system(_map_id, _location, _old_location, _map_opts), do: :ok
defp do_add_system(
map_id,
%{
@@ -679,7 +754,11 @@ defmodule WandererApp.Map.Server.SystemsImpl do
_ ->
%{x: x, y: y} =
WandererApp.Map.PositionCalculator.get_new_system_position(nil, rtree_name, map_opts)
WandererApp.Map.PositionCalculator.get_new_system_position(
nil,
rtree_name,
map_opts
)
%{"x" => x, "y" => y}
end
@@ -742,7 +821,10 @@ defmodule WandererApp.Map.Server.SystemsImpl do
})
{:error, reason} ->
Logger.error("Failed to get system static info for #{solar_system_id}: #{inspect(reason)}")
Logger.error(
"Failed to get system static info for #{solar_system_id}: #{inspect(reason)}"
)
{:error, :system_info_not_found}
end
end
@@ -775,7 +857,10 @@ defmodule WandererApp.Map.Server.SystemsImpl do
:ok
{:error, reason} = error ->
Logger.error("Failed to add system #{solar_system_id} to map #{map_id}: #{inspect(reason)}")
Logger.error(
"Failed to add system #{solar_system_id} to map #{map_id}: #{inspect(reason)}"
)
error
end
else
@@ -863,10 +948,8 @@ defmodule WandererApp.Map.Server.SystemsImpl do
updated_system
end
defp maybe_update_labels(system, _labels), do: system
defp maybe_update_labels(
%{name: old_labels} = system,
%{labels: old_labels} = system,
labels
)
when not is_nil(labels) and old_labels != labels do
@@ -980,12 +1063,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
# This may fail if the relay is not available (e.g., in tests), which is fine
WandererApp.ExternalEvents.broadcast(map_id, :system_metadata_changed, %{
system_id: updated_system.id,
solar_system_id: updated_system.solar_system_id,
name: updated_system.name,
temporary_name: updated_system.temporary_name,
labels: updated_system.labels,
description: updated_system.description,
status: updated_system.status
status: updated_system.status,
locked: updated_system.locked,
position_x: updated_system.position_x,
position_y: updated_system.position_y
})
:ok

View File

@@ -128,7 +128,7 @@ defmodule WandererApp.Maps do
tracked: tracked
}
defp get_map_characters(%{id: map_id} = map) do
defp get_map_characters(%{id: map_id} = _map) do
WandererApp.Cache.lookup!("map_characters-#{map_id}")
|> case do
nil ->
@@ -174,9 +174,11 @@ defmodule WandererApp.Maps do
map_member_alliance_ids: map_member_alliance_ids
}
# Cache with 5 minute TTL so ACL changes are picked up even when map server isn't running
WandererApp.Cache.insert(
"map_characters-#{map_id}",
map_characters
map_characters,
ttl: :timer.minutes(5)
)
{:ok, map_characters}

View File

@@ -99,7 +99,7 @@ defmodule WandererApp.MapConnectionRepo do
def get_by_id(map_id, id) do
# Use read_by_map action which doesn't have the FilterConnectionsByActorMap preparation
# that was causing "filter being false" errors in tests
import Ash.Query
require Ash.Query
WandererApp.Api.MapConnection
|> Ash.Query.for_read(:read_by_map, %{map_id: map_id})

View File

@@ -38,6 +38,4 @@ defmodule WandererApp.MapPingsRepo do
:ok
end
def destroy(_ping_id), do: :ok
end

View File

@@ -84,7 +84,7 @@ defmodule WandererApp.MapRepo do
end
end
error in Ash.Error.Query.NotFound ->
_error in Ash.Error.Query.NotFound ->
Logger.debug("Map not found with slug: #{slug}")
{:error, :not_found}

View File

@@ -487,15 +487,6 @@ defmodule WandererApp.SecurityAudit do
# Private functions
defp store_audit_entry(_audit_entry) do
# Handle async processing if enabled
# if async_enabled?() do
# WandererApp.SecurityAudit.AsyncProcessor.log_event(audit_entry)
# else
# do_store_audit_entry(audit_entry)
# end
end
@doc false
def do_store_audit_entry(audit_entry) do
# Ensure event_type is properly formatted
@@ -631,11 +622,6 @@ defmodule WandererApp.SecurityAudit do
end
end
defp async_enabled? do
Application.get_env(:wanderer_app, __MODULE__, [])
|> Keyword.get(:async, false)
end
defp emit_telemetry_event(audit_entry) do
:telemetry.execute(
[:wanderer_app, :security_audit],

View File

@@ -5,7 +5,11 @@ defmodule WandererApp.Test.Logger do
"""
@callback info(message :: iodata() | (-> iodata())) :: :ok
@callback info(message :: iodata() | (-> iodata()), metadata :: keyword()) :: :ok
@callback error(message :: iodata() | (-> iodata())) :: :ok
@callback error(message :: iodata() | (-> iodata()), metadata :: keyword()) :: :ok
@callback warning(message :: iodata() | (-> iodata())) :: :ok
@callback warning(message :: iodata() | (-> iodata()), metadata :: keyword()) :: :ok
@callback debug(message :: iodata() | (-> iodata())) :: :ok
@callback debug(message :: iodata() | (-> iodata()), metadata :: keyword()) :: :ok
end

View File

@@ -9,12 +9,24 @@ defmodule WandererApp.Test.LoggerStub do
@impl true
def info(_message), do: :ok
@impl true
def info(_message, _metadata), do: :ok
@impl true
def error(_message), do: :ok
@impl true
def error(_message, _metadata), do: :ok
@impl true
def warning(_message), do: :ok
@impl true
def warning(_message, _metadata), do: :ok
@impl true
def debug(_message), do: :ok
@impl true
def debug(_message, _metadata), do: :ok
end

View File

@@ -124,7 +124,7 @@ defmodule WandererApp.Vault do
end)
end
defp find_fallback_module_to_decrypt(config, ciphertext) do
defp find_fallback_module_to_decrypt(config, _ciphertext) do
Enum.find(config[:ciphers], fn {label, _} ->
label == :fallback
end)

View File

@@ -12,7 +12,6 @@ defmodule WandererAppWeb.ApiRouter do
"""
use Phoenix.Router
import WandererAppWeb.ApiRouterHelpers
alias WandererAppWeb.{ApiRoutes, ApiRouter.RouteSpec}
require Logger
@@ -171,7 +170,7 @@ defmodule WandererAppWeb.ApiRouter do
|> halt()
end
defp find_similar_routes(path_info, version) do
defp find_similar_routes(path_info, _version) do
# Find routes with similar paths in current or other versions
all_routes = ApiRoutes.table()

View File

@@ -1,7 +1,7 @@
defmodule WandererAppWeb.ApiSpec do
@behaviour OpenApiSpex.OpenApi
alias OpenApiSpex.{OpenApi, Info, Paths, Components, SecurityScheme, Server, Schema}
alias OpenApiSpex.{OpenApi, Info, Paths, Components, SecurityScheme, Server}
alias WandererAppWeb.{Endpoint, Router}
alias WandererAppWeb.Schemas.ApiSchemas

View File

@@ -284,6 +284,7 @@ defmodule WandererAppWeb.CoreComponents do
"""
attr(:type, :string, default: nil)
attr(:class, :string, default: nil)
attr(:data, :any, default: nil)
attr(:rest, :global, include: ~w(disabled form name value))
slot(:inner_block, required: true)
@@ -296,6 +297,7 @@ defmodule WandererAppWeb.CoreComponents do
"phx-submit-loading:opacity-75 p-button p-component p-button-outlined p-button-sm",
@class
]}
data={@data}
{@rest}
>
{render_slot(@inner_block)}
@@ -614,7 +616,7 @@ defmodule WandererAppWeb.CoreComponents do
attr(:empty_label, :string, default: nil)
attr(:rows, :list, required: true)
attr(:row_id, :any, default: nil, doc: "the function for generating the row id")
attr(:row_selected, :boolean, default: false, doc: "the function for generating the row id")
attr(:row_selected, :any, default: false, doc: "the function for generating the row id")
attr(:row_click, :any, default: nil, doc: "the function for handling phx-click on each row")
attr(:row_item, :any,
@@ -703,13 +705,21 @@ defmodule WandererAppWeb.CoreComponents do
"""
end
attr(:field, :any, required: true)
attr(:placeholder, :string, default: nil)
attr(:label, :string, default: nil)
attr(:label_class, :string, default: nil)
attr(:input_class, :string, default: nil)
attr(:dropdown_extra_class, :string, default: nil)
attr(:option_extra_class, :string, default: nil)
attr(:mode, :atom, default: :single)
attr(:options, :list, default: [])
attr(:debounce, :integer, default: nil)
attr(:update_min_len, :integer, default: nil)
attr(:available_option_class, :string, default: nil)
attr(:value_mapper, :any, default: nil)
slot(:inner_block)
slot(:option)
def live_select(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
assigns =

View File

@@ -433,6 +433,10 @@ defmodule WandererAppWeb.AccessListMemberAPIController do
# ---------------------------------------------------------------------------
defp broadcast_acl_updated(acl_id) do
# Invalidate map_characters cache for all maps using this ACL
# This ensures the tracking page shows updated members even when map server isn't running
invalidate_map_characters_cache(acl_id)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"acls:#{acl_id}",
@@ -440,6 +444,23 @@ defmodule WandererAppWeb.AccessListMemberAPIController do
)
end
defp invalidate_map_characters_cache(acl_id) do
case Ash.read(
WandererApp.Api.MapAccessList
|> Ash.Query.for_read(:read_by_acl, %{acl_id: acl_id})
) do
{:ok, map_acls} ->
Enum.each(map_acls, fn %{map_id: map_id} ->
WandererApp.Cache.delete("map_characters-#{map_id}")
end)
{:error, error} ->
Logger.warning(
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
)
end
end
@doc false
defp member_to_json(member) do
base = %{

View File

@@ -42,12 +42,18 @@ defmodule WandererAppWeb.AuthController do
WandererApp.Character.update_character(character.id, character_update)
# Update corporation/alliance data from ESI to ensure access control is current
update_character_affiliation(character)
{:ok, character}
{:error, _error} ->
{:ok, character} = WandererApp.Api.Character.create(character_data)
:telemetry.execute([:wanderer_app, :user, :character, :registered], %{count: 1})
# Fetch initial corporation/alliance data for new characters
update_character_affiliation(character)
{:ok, character}
end
@@ -113,4 +119,102 @@ defmodule WandererAppWeb.AuthController do
end
def maybe_update_character_user_id(_character, _user_id), do: :ok
# Updates character's corporation and alliance data from ESI.
# This ensures ACL-based access control uses current corporation membership,
# even for characters not actively being tracked on any map.
defp update_character_affiliation(%{id: character_id, eve_id: eve_id} = character) do
# Run async to not block the SSO callback
Task.start(fn ->
character_eve_id = eve_id |> String.to_integer()
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
{:ok, [affiliation_info]} when is_map(affiliation_info) ->
new_corporation_id = Map.get(affiliation_info, "corporation_id")
new_alliance_id = Map.get(affiliation_info, "alliance_id")
# Check if corporation changed
corporation_changed = character.corporation_id != new_corporation_id
alliance_changed = character.alliance_id != new_alliance_id
if corporation_changed or alliance_changed do
update_affiliation_data(character_id, character, new_corporation_id, new_alliance_id)
end
{:error, error} ->
Logger.warning(
"[AuthController] Failed to fetch affiliation for character #{character_id}: #{inspect(error)}"
)
_ ->
:ok
end
end)
end
defp update_character_affiliation(_character), do: :ok
defp update_affiliation_data(character_id, character, corporation_id, alliance_id) do
# Fetch corporation info
corporation_update =
case WandererApp.Esi.get_corporation_info(corporation_id) do
{:ok, %{"name" => corp_name, "ticker" => corp_ticker}} ->
%{
corporation_id: corporation_id,
corporation_name: corp_name,
corporation_ticker: corp_ticker
}
_ ->
%{corporation_id: corporation_id}
end
# Fetch alliance info if present
alliance_update =
case alliance_id do
nil ->
%{alliance_id: nil, alliance_name: nil, alliance_ticker: nil}
_ ->
case WandererApp.Esi.get_alliance_info(alliance_id) do
{:ok, %{"name" => alliance_name, "ticker" => alliance_ticker}} ->
%{
alliance_id: alliance_id,
alliance_name: alliance_name,
alliance_ticker: alliance_ticker
}
_ ->
%{alliance_id: alliance_id}
end
end
full_update = Map.merge(corporation_update, alliance_update)
# Update database
case character.corporation_id != corporation_id do
true ->
{:ok, _} = WandererApp.Api.Character.update_corporation(character, corporation_update)
false ->
:ok
end
case character.alliance_id != alliance_id do
true ->
{:ok, _} = WandererApp.Api.Character.update_alliance(character, alliance_update)
false ->
:ok
end
# Update cache
WandererApp.Character.update_character(character_id, full_update)
Logger.info(
"[AuthController] Updated affiliation for character #{character_id}: " <>
"corp #{character.corporation_id} -> #{corporation_id}, " <>
"alliance #{character.alliance_id} -> #{alliance_id}"
)
end
end

View File

@@ -123,12 +123,6 @@ defmodule WandererAppWeb.LicenseApiController do
end
end
def update_validity(conn, %{"id" => _license_id}) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing required parameter: is_valid"})
end
@doc """
Updates a license's expiration date.

View File

@@ -2,12 +2,10 @@ defmodule WandererAppWeb.MapAPIController do
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
import Ash.Query, only: [filter: 2]
require Ash.Query
require Logger
alias WandererApp.Api.Character
alias WandererApp.MapSystemRepo
alias WandererApp.MapCharacterSettingsRepo
alias WandererApp.MapConnectionRepo
alias WandererAppWeb.Helpers.APIUtils
alias WandererAppWeb.Schemas.{ApiSchemas, ResponseSchemas}
@@ -16,7 +14,7 @@ defmodule WandererAppWeb.MapAPIController do
# V1 API Actions (for compatibility with versioned API router)
# -----------------------------------------------------------------
def index_v1(conn, params) do
def index_v1(conn, _params) do
# Delegate to the existing list implementation or create a basic one
json(conn, %{
data: [],
@@ -43,7 +41,7 @@ defmodule WandererAppWeb.MapAPIController do
})
end
def create_v1(conn, params) do
def create_v1(conn, _params) do
# Basic create implementation for testing
json(conn, %{
data: %{
@@ -59,7 +57,7 @@ defmodule WandererAppWeb.MapAPIController do
})
end
def update_v1(conn, %{"id" => id} = params) do
def update_v1(conn, %{"id" => id} = _params) do
# Basic update implementation for testing
json(conn, %{
data: %{
@@ -82,7 +80,7 @@ defmodule WandererAppWeb.MapAPIController do
|> text("")
end
def duplicate_v1(conn, %{"id" => id} = params) do
def duplicate_v1(conn, %{"id" => id} = _params) do
# Basic duplicate implementation for testing
json(conn, %{
data: %{
@@ -99,7 +97,7 @@ defmodule WandererAppWeb.MapAPIController do
})
end
def bulk_create_v1(conn, params) do
def bulk_create_v1(conn, _params) do
# Basic bulk create implementation for testing
json(conn, %{
data: [
@@ -121,7 +119,7 @@ defmodule WandererAppWeb.MapAPIController do
})
end
def bulk_update_v1(conn, params) do
def bulk_update_v1(conn, _params) do
# Basic bulk update implementation for testing
json(conn, %{
data: [
@@ -325,13 +323,6 @@ defmodule WandererAppWeb.MapAPIController do
# Helper functions for the API controller
# -----------------------------------------------------------------
defp get_map_id_by_slug(slug) do
case WandererApp.Api.Map.get_map_by_slug(slug) do
{:ok, map} -> {:ok, map.id}
{:error, error} -> {:error, "Map not found for slug: #{slug}, error: #{inspect(error)}"}
end
end
defp normalize_map_identifier(params) do
case Map.get(params, "map_identifier") do
nil ->

View File

@@ -4,8 +4,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
require Logger
alias WandererApp.Api
alias WandererAppWeb.UserActivityItem
alias WandererAppWeb.Helpers.APIUtils
# -----------------------------------------------------------------
@@ -155,10 +154,10 @@ defmodule WandererAppWeb.MapAuditAPIController do
result
|> Map.put(:character, WandererAppWeb.MapEventHandler.map_ui_character_stat(character))
|> Map.put(:event_name, WandererAppWeb.UserActivity.get_event_name(event_type))
|> Map.put(:event_name, WandererAppWeb.UserActivityItem.get_event_name(event_type))
|> Map.put(
:event_data,
WandererAppWeb.UserActivity.get_event_data(
WandererAppWeb.UserActivityItem.get_event_data(
event_type,
Jason.decode!(event_data) |> Map.drop(["character_id"])
)

View File

@@ -65,24 +65,6 @@ defmodule WandererAppWeb.MapEventsAPIController do
items: @event_schema
})
@events_list_params %OpenApiSpex.Schema{
type: :object,
properties: %{
since: %OpenApiSpex.Schema{
type: :string,
format: :date_time,
description: "Return events after this timestamp (ISO8601)"
},
limit: %OpenApiSpex.Schema{
type: :integer,
minimum: 1,
maximum: 100,
default: 100,
description: "Maximum number of events to return"
}
}
}
# -----------------------------------------------------------------
# OpenApiSpex Operations
# -----------------------------------------------------------------
@@ -173,7 +155,7 @@ defmodule WandererAppWeb.MapEventsAPIController do
|> put_status(:bad_request)
|> json(%{error: "Invalid 'limit' parameter. Must be between 1 and 100."})
{:error, reason} ->
{:error, _reason} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
@@ -184,7 +166,7 @@ defmodule WandererAppWeb.MapEventsAPIController do
# Private Functions
# -----------------------------------------------------------------
defp get_map(conn, map_identifier) do
defp get_map(conn, _map_identifier) do
# The map should already be loaded by the CheckMapApiKey plug
case conn.assigns[:map] do
nil -> {:error, :map_not_found}

View File

@@ -36,7 +36,7 @@ defmodule WandererAppWeb.Plugs.JsonApiPerformanceMonitor do
conn
|> register_before_send(fn conn ->
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
_duration = end_time - start_time
# Extract response metadata
response_metadata = extract_response_metadata(conn, request_metadata)

View File

@@ -12,7 +12,6 @@ defmodule WandererAppWeb.Plugs.LicenseAuth do
require Logger
alias WandererApp.License.LicenseManager
alias WandererApp.Helpers.Config
@doc """
Authenticates requests using the LM_AUTH_KEY.
@@ -21,7 +20,7 @@ defmodule WandererAppWeb.Plugs.LicenseAuth do
"""
def authenticate_lm(conn, _opts) do
auth_header = get_req_header(conn, "authorization")
lm_auth_key = Config.get_env(:wanderer_app, :lm_auth_key)
lm_auth_key = Application.get_env(:wanderer_app, :lm_auth_key)
case auth_header do
["Bearer " <> token] ->

View File

@@ -37,7 +37,7 @@ defmodule WandererAppWeb.UserAuth do
nil ->
{:halt, redirect_require_login(socket)}
%User{characters: characters} ->
%User{characters: _characters} ->
{:cont, new_socket}
end
@@ -112,13 +112,6 @@ defmodule WandererAppWeb.UserAuth do
|> LiveView.redirect(to: ~p"/")
end
defp track_characters([]), do: :ok
defp track_characters([%{id: character_id} | characters]) do
:ok = WandererApp.Character.TrackerManager.start_tracking(character_id)
track_characters(characters)
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
%{request_path: request_path, query_string: query_string} = conn
return_to = if query_string == "", do: request_path, else: request_path <> "?" <> query_string

View File

@@ -2,8 +2,11 @@ defmodule WandererAppWeb.AccessListsLive do
use WandererAppWeb, :live_view
alias WandererApp.ExternalEvents.AclEventBroadcaster
require Ash.Query
require Logger
@members_per_page 50
@impl true
def mount(_params, %{"user_id" => user_id} = _session, socket) when not is_nil(user_id) do
{:ok, characters} = WandererApp.Api.Character.active_by_user(%{user_id: user_id})
@@ -24,7 +27,9 @@ defmodule WandererAppWeb.AccessListsLive do
user_id: user_id,
access_lists: access_lists |> Enum.map(fn acl -> map_ui_acl(acl, nil) end),
characters: characters,
members: []
members: [],
members_page: 1,
members_per_page: @members_per_page
)}
end
@@ -38,7 +43,9 @@ defmodule WandererAppWeb.AccessListsLive do
allow_acl_creation: false,
access_lists: [],
characters: [],
members: []
members: [],
members_page: 1,
members_per_page: @members_per_page
)}
end
@@ -92,10 +99,8 @@ defmodule WandererAppWeb.AccessListsLive do
|> assign(:page_title, "Access Lists - Members")
|> assign(:selected_acl_id, acl_id)
|> assign(:access_list, access_list)
|> assign(
:members,
members
)
|> assign(:members, members)
|> assign(:members_page, 1)
else
_ ->
socket
@@ -281,11 +286,7 @@ defmodule WandererAppWeb.AccessListsLive do
|> Enum.find(&(&1.id == member_id))
|> WandererApp.Api.AccessListMember.destroy!()
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"acls:#{socket.assigns.selected_acl_id}",
{:acl_updated, %{acl_id: socket.assigns.selected_acl_id}}
)
broadcast_acl_updated(socket.assigns.selected_acl_id)
{:noreply,
socket
@@ -327,6 +328,20 @@ defmodule WandererAppWeb.AccessListsLive do
{:noreply, assign(socket, form: form)}
end
@impl true
def handle_event("members_prev_page", _, socket) do
new_page = max(1, socket.assigns.members_page - 1)
{:noreply, assign(socket, :members_page, new_page)}
end
@impl true
def handle_event("members_next_page", _, socket) do
total_members = length(socket.assigns.members)
max_page = max(1, ceil(total_members / socket.assigns.members_per_page))
new_page = min(max_page, socket.assigns.members_page + 1)
{:noreply, assign(socket, :members_page, new_page)}
end
@impl true
def handle_event("noop", _, socket) do
{:noreply, socket}
@@ -444,11 +459,7 @@ defmodule WandererAppWeb.AccessListsLive do
:telemetry.execute([:wanderer_app, :acl, :member, :update], %{count: 1})
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"acls:#{socket.assigns.selected_acl_id}",
{:acl_updated, %{acl_id: socket.assigns.selected_acl_id}}
)
broadcast_acl_updated(socket.assigns.selected_acl_id)
socket
|> assign(
@@ -574,11 +585,7 @@ defmodule WandererAppWeb.AccessListsLive do
:telemetry.execute([:wanderer_app, :acl, :member, :add], %{count: 1})
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"acls:#{access_list_id}",
{:acl_updated, %{acl_id: access_list_id}}
)
broadcast_acl_updated(access_list_id)
{:ok, member}
@@ -613,11 +620,7 @@ defmodule WandererAppWeb.AccessListsLive do
:telemetry.execute([:wanderer_app, :acl, :member, :add], %{count: 1})
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"acls:#{access_list_id}",
{:acl_updated, %{acl_id: access_list_id}}
)
broadcast_acl_updated(access_list_id)
{:ok, member}
@@ -653,11 +656,7 @@ defmodule WandererAppWeb.AccessListsLive do
:telemetry.execute([:wanderer_app, :acl, :member, :add], %{count: 1})
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"acls:#{access_list_id}",
{:acl_updated, %{acl_id: access_list_id}}
)
broadcast_acl_updated(access_list_id)
{:ok, member}
@@ -688,7 +687,7 @@ defmodule WandererAppWeb.AccessListsLive do
"""
end
slot(:option)
attr(:option, :any, required: true)
def search_member_item(assigns) do
~H"""
@@ -737,4 +736,44 @@ defmodule WandererAppWeb.AccessListsLive do
defp map_ui_acl(acl, selected_id) do
acl |> Map.put(:selected, acl.id == selected_id)
end
defp paginated_members(members, page, per_page) do
members
|> Enum.sort_by(&{&1.role, &1.name}, &<=/2)
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
defp total_pages(members, per_page) do
max(1, ceil(length(members) / per_page))
end
# Broadcast ACL update and invalidate map_characters cache for all maps using this ACL
# This ensures the tracking page shows updated members even when map server isn't running
defp broadcast_acl_updated(acl_id) do
invalidate_map_characters_cache(acl_id)
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"acls:#{acl_id}",
{:acl_updated, %{acl_id: acl_id}}
)
end
defp invalidate_map_characters_cache(acl_id) do
case Ash.read(
WandererApp.Api.MapAccessList
|> Ash.Query.for_read(:read_by_acl, %{acl_id: acl_id})
) do
{:ok, map_acls} ->
Enum.each(map_acls, fn %{map_id: map_id} ->
WandererApp.Cache.delete("map_characters-#{map_id}")
end)
{:error, error} ->
Logger.warning(
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
)
end
end
end

View File

@@ -82,11 +82,14 @@
</h3>
<div
class="dropzone droppable draggable-dropzone--occupied flex flex-col gap-1 w-full rounded-none h-[calc(100vh-211px)] !overflow-y-auto"
class={[
"dropzone droppable draggable-dropzone--occupied flex flex-col gap-1 w-full rounded-none h-[calc(100vh-191px)] !overflow-y-auto",
classes("!h-[calc(100vh-240px)]": length(@members) > @members_per_page)
]}
id="acl_members"
>
<div
:for={member <- @members |> Enum.sort_by(&{&1.role, &1.name}, &<=/2)}
:for={member <- paginated_members(@members, @members_page, @members_per_page)}
draggable="true"
id={member.id}
class="draggable !p-1 h-10 cursor-move bg-black bg-opacity-25 hover:text-white"
@@ -113,15 +116,45 @@
</div>
</div>
</div>
<div>
<div :if={length(@members) > @members_per_page} class="flex items-center justify-between px-3 py-2 border-t border-gray-500 bg-black bg-opacity-25">
<span class="text-sm text-gray-400">
Page {@members_page} of {total_pages(@members, @members_per_page)} ({length(@members)} members)
</span>
<div class="flex gap-2">
<button
phx-click="members_prev_page"
disabled={@members_page <= 1}
class={"btn btn-sm btn-ghost " <> if(@members_page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="members_next_page"
disabled={@members_page >= total_pages(@members, @members_per_page)}
class={"btn btn-sm btn-ghost " <> if(@members_page >= total_pages(@members, @members_per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<.link
disabled={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none"
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
class="btn w-full btn-neutral rounded-none"
patch={~p"/access-lists/#{@selected_acl_id}/add-members"}
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</.link>
<div
:if={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none btn-disabled"
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</div>
</div>
</div>
</main>
</div>
@@ -146,10 +179,10 @@
placeholder="Select an owner"
options={Enum.map(@characters, fn character -> {character.label, character.id} end)}
/>
<!-- Divider between above inputs and the API key section -->
<hr class="my-4 border-gray-600" />
<!-- API Key Section with grid layout -->
<div class="mt-2">
<label class="block text-sm font-medium text-gray-200 mb-1">ACL API key</label>

View File

@@ -4,8 +4,6 @@ defmodule WandererAppWeb.AdminLive do
require Logger
alias BetterNumber, as: Number
@invite_link_ttl :timer.hours(24)
def mount(_params, %{"user_id" => user_id} = _session, socket)
when not is_nil(user_id) do
WandererApp.StartCorpWalletTrackerTask.maybe_start_corp_wallet_tracker(

View File

@@ -209,7 +209,7 @@
rows={@transactions}
class="!max-h-[40vh] !overflow-y-auto"
>
<:col :let={transaction}>
<:col :let={_transaction}>
<div class=" text-22">
<.icon name="hero-credit-card-solid" class="h-5 w-5" />
</div>
@@ -267,7 +267,7 @@
rows={@active_map_subscriptions}
class="!max-h-[40vh] !overflow-y-auto"
>
<:col :let={subscription}>
<:col :let={_subscription}>
<div class=" text-22">
<.icon name="hero-check-badge-solid" class="w-5 h-5" />
</div>

View File

@@ -3,6 +3,8 @@ defmodule WandererAppWeb.CharactersTrackingLive do
require Logger
alias WandererApp.Character.TrackingUtils
@impl true
def mount(_params, _session, socket) do
{:ok, maps} = WandererApp.Maps.get_available_maps(socket.assigns.current_user)
@@ -18,22 +20,19 @@ defmodule WandererAppWeb.CharactersTrackingLive do
)}
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(characters: [], selected_map: nil, maps: [])}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :index, _params) do
# Unsubscribe from previous map if any
socket = maybe_unsubscribe_from_map(socket)
socket
|> assign(:active_page, :characters_tracking)
|> assign(:page_title, "Characters Tracking")
|> assign(selected_map: nil, selected_map_slug: nil)
end
defp apply_action(
@@ -43,6 +42,10 @@ defmodule WandererAppWeb.CharactersTrackingLive do
) do
selected_map = maps |> Enum.find(&(&1.slug == map_slug))
# Unsubscribe from previous map and subscribe to new one
socket = maybe_unsubscribe_from_map(socket)
socket = maybe_subscribe_to_map(socket, selected_map)
socket
|> assign(:active_page, :characters_tracking)
|> assign(:page_title, "Characters Tracking")
@@ -55,6 +58,27 @@ defmodule WandererAppWeb.CharactersTrackingLive do
end)
end
# Subscribe to map PubSub channel to receive ACL update notifications
defp maybe_subscribe_to_map(socket, nil), do: socket
defp maybe_subscribe_to_map(socket, %{id: map_id}) do
if connected?(socket) do
Phoenix.PubSub.subscribe(WandererApp.PubSub, map_id)
end
socket
end
# Unsubscribe from previous map's PubSub channel
defp maybe_unsubscribe_from_map(%{assigns: %{selected_map: nil}} = socket), do: socket
defp maybe_unsubscribe_from_map(%{assigns: %{selected_map: %{id: map_id}}} = socket) do
Phoenix.PubSub.unsubscribe(WandererApp.PubSub, map_id)
socket
end
defp maybe_unsubscribe_from_map(socket), do: socket
@impl true
def handle_event("select_map_" <> map_slug, _, socket) do
{:noreply,
@@ -77,21 +101,30 @@ defmodule WandererAppWeb.CharactersTrackingLive do
%{result: characters} = socket.assigns.characters
case characters |> Enum.find(&(&1.id == character_id)) do
%{tracked: false} ->
WandererApp.MapCharacterSettingsRepo.track(%{
character_id: character_id,
map_id: selected_map.id
})
%{tracked: current_tracked, eve_id: eve_id} ->
# Use TrackingUtils.update_tracking to properly set/unset the tracking_start_time
# cache key, which is required for the character to appear in get_tracked_character_ids
case TrackingUtils.update_tracking(
selected_map.id,
eve_id,
current_user.id,
not current_tracked,
self(),
false
) do
{:ok, _tracking_data, _event} ->
:ok
%{tracked: true} ->
WandererApp.MapCharacterSettingsRepo.untrack(%{
character_id: character_id,
map_id: selected_map.id
})
{:error, reason} ->
Logger.error(
"Failed to toggle tracking for character #{character_id} on map #{selected_map.id}: #{inspect(reason)}"
)
end
WandererApp.Map.Server.untrack_characters(selected_map.id, [
character_id
])
nil ->
Logger.warning(
"Character #{character_id} not found in available characters for map #{selected_map.id}"
)
end
{:noreply,
@@ -111,6 +144,20 @@ defmodule WandererAppWeb.CharactersTrackingLive do
{:noreply, socket}
end
# Handle ACL members changed event - reload characters list
@impl true
def handle_info(
%{event: :acl_members_changed},
%{assigns: %{selected_map: selected_map, current_user: current_user}} = socket
)
when not is_nil(selected_map) do
{:noreply,
socket
|> assign_async(:characters, fn ->
WandererApp.Maps.load_characters(selected_map, current_user.id)
end)}
end
@impl true
def handle_info(_event, socket), do: {:noreply, socket}
end

View File

@@ -79,7 +79,7 @@ defmodule WandererAppWeb.MapSubscription do
{:noreply, socket}
end
defp get_title(%{plan: plan, auto_renew?: auto_renew?, active_till: active_till} = subscription) do
defp get_title(%{plan: plan, auto_renew?: auto_renew?, active_till: active_till}) do
if plan != :alpha do
"Active subscription: omega \nActive till: #{Calendar.strftime(active_till, "%m/%d/%Y")} \nAuto renew: #{auto_renew?}"
else

View File

@@ -1,4 +1,4 @@
defmodule WandererAppWeb.UserActivity do
defmodule WandererAppWeb.UserActivityItem do
use WandererAppWeb, :live_component
use LiveViewEvents

View File

@@ -300,13 +300,13 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
%{"character_eve_id" => character_eve_id},
%{
assigns: %{
map_id: map_id,
current_user: %{id: current_user_id}
map_id: _map_id,
current_user: %{id: _current_user_id}
}
} = socket
)
when not is_nil(character_eve_id) do
{:ok, character} = WandererApp.Character.get_by_eve_id("#{character_eve_id}")
{:ok, _character} = WandererApp.Character.get_by_eve_id("#{character_eve_id}")
{:noreply, socket}
end
@@ -338,12 +338,6 @@ defmodule WandererAppWeb.MapCharactersEventHandler do
station_id: character.station_id
}
defp get_map_with_acls(map_id) do
with {:ok, map} <- WandererApp.Api.Map.by_id(map_id) do
{:ok, Ash.load!(map, :acls)}
end
end
def needs_tracking_setup?(
only_tracked_characters,
characters,

View File

@@ -120,10 +120,16 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
{:ok, signatures} =
WandererApp.Api.MapSystemSignature.by_linked_system_id(solar_system_target_id)
signatures
|> Enum.filter(fn s ->
s.system_id == source_system.id
end)
filtered_signatures =
signatures
|> Enum.filter(fn s ->
s.system_id == source_system.id
end)
# Collect eve_ids for audit logging
deleted_eve_ids = Enum.map(filtered_signatures, & &1.eve_id)
filtered_signatures
|> Enum.each(fn s ->
if not is_nil(s.temporary_name) && s.temporary_name == target_system.temporary_name do
map_id
@@ -143,6 +149,17 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
|> WandererApp.Api.MapSystemSignature.destroy!()
end)
# Audit log signatures deleted with connection
if deleted_eve_ids != [] do
WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_id: solar_system_source_id,
signatures: deleted_eve_ids
})
end
WandererApp.Map.Server.Impl.broadcast!(
map_id,
:signatures_updated,

View File

@@ -11,7 +11,7 @@ defmodule WandererAppWeb.MapKillsEventHandler do
def handle_server_event(
%{event: :init_kills},
%{assigns: %{map_id: map_id} = assigns} = socket
%{assigns: %{map_id: map_id} = _assigns} = socket
) do
# Get kill counts from cache
case WandererApp.Map.get_map(map_id) do

View File

@@ -3,7 +3,7 @@ defmodule WandererAppWeb.MapRoutesEventHandler do
use Phoenix.Component
require Logger
alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler, MapSystemsEventHandler}
alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler}
def handle_server_event(
%{

View File

@@ -168,7 +168,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
current_user: %{id: current_user_id},
map_id: map_id,
main_character_id: main_character_id,
map_user_settings: map_user_settings,
map_user_settings: _map_user_settings,
user_permissions: %{update_system: true}
} = assigns
} = socket
@@ -380,7 +380,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
def handle_ui_event(
"undo_delete_signatures",
%{"system_id" => solar_system_id, "eve_ids" => eve_ids} = payload,
%{"system_id" => solar_system_id, "eve_ids" => eve_ids} = _payload,
%{
assigns: %{
map_id: map_id,

View File

@@ -97,7 +97,7 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do
%{"solarSystemId" => solar_system_id} = _event,
%{
assigns: %{
current_user: current_user,
current_user: _current_user,
has_tracked_characters?: true,
map_id: map_id,
user_permissions: %{add_system: true}
@@ -109,7 +109,7 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do
solar_system_id: solar_system_id
})
|> case do
%{id: system_id} = system when not is_nil(system_id) ->
%{id: system_id} = _system when not is_nil(system_id) ->
{:ok, comments} = WandererApp.MapSystemCommentRepo.get_by_system(system_id)
{:reply,

View File

@@ -3,10 +3,6 @@ defmodule WandererAppWeb.MapAuditLive do
require Logger
alias WandererAppWeb.UserActivity
@active_subscription_periods ["2M", "3M"]
def mount(
%{"slug" => map_slug, "period" => period, "activity" => activity} = _params,
_session,

View File

@@ -109,7 +109,7 @@
/>
</div>
<.live_component
module={UserActivity}
module={WandererAppWeb.UserActivityItem}
id="user-activity"
notify_to={self()}
can_undo_types={@can_undo_types}

View File

@@ -157,7 +157,7 @@ defmodule WandererAppWeb.MapCharactersLive do
|> assign(:groups, groups)
end
defp map_ui_character(map_id, character) do
defp map_ui_character(_map_id, character) do
character
|> Map.take([
:id,

View File

@@ -230,6 +230,7 @@ defmodule WandererAppWeb.MapEventHandler do
def handle_event(socket, {:DOWN, ref, :process, _pid, reason}) when is_reference(ref) do
# Task failed, log the error and update the client
Logger.error("Task failed: #{inspect(reason)}")
socket
end
def handle_event(socket, event),

View File

@@ -112,13 +112,6 @@ defmodule WandererAppWeb.MapLive do
|> WandererAppWeb.MapEventHandler.handle_event(info)}
end
@impl true
def handle_info(info, socket),
do:
{:noreply,
socket
|> WandererAppWeb.MapEventHandler.handle_event(info)}
@impl true
def handle_event("change_subscription_tab", %{"tab" => tab}, socket),
do: {:noreply, socket |> assign(active_subscription_tab: tab)}

View File

@@ -5,7 +5,6 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
require Logger
alias BetterNumber, as: Number
alias WandererApp.License.LicenseManager
@impl true
def mount(socket) do
@@ -99,7 +98,7 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
type: :in
})
{:ok, user} =
{:ok, _user} =
user
|> WandererApp.Api.User.update_balance(%{
balance: (user_balance || 0.0) - amount

View File

@@ -77,7 +77,7 @@ defmodule WandererAppWeb.MapsLive do
|> assign(:active_page, :maps)
|> assign(:uri, URI.parse(url) |> Map.put(:path, ~p"/"))
|> assign(:page_title, "Maps - Create")
|> assign(:scopes, ["wormholes", "stargates", "none", "all"])
|> assign(:available_scopes, available_scopes())
|> assign(
:form,
AshPhoenix.Form.for_create(WandererApp.Api.Map, :new,
@@ -86,7 +86,8 @@ defmodule WandererAppWeb.MapsLive do
],
prepare_source: fn form ->
form
|> Map.put("scope", "wormholes")
# Default to wormholes scope for new maps
|> Map.put("scopes", [:wormholes])
end
)
)
@@ -115,6 +116,9 @@ defmodule WandererAppWeb.MapsLive do
_ -> map |> map_map()
end
# Auto-initialize scopes from legacy scope if scopes is empty/nil
map = maybe_initialize_scopes_from_legacy(map)
# Add owner to characters list, filtering out nil values
characters =
[map.owner |> map_character() | socket.assigns.characters]
@@ -125,7 +129,7 @@ defmodule WandererAppWeb.MapsLive do
|> assign(:active_page, :maps)
|> assign(:uri, URI.parse(url) |> Map.put(:path, ~p"/"))
|> assign(:page_title, "Maps - Edit")
|> assign(:scopes, ["wormholes", "stargates", "none", "all"])
|> assign(:available_scopes, available_scopes())
|> assign(:map_slug, map_slug)
|> assign(:characters, characters)
|> assign(
@@ -215,13 +219,6 @@ defmodule WandererAppWeb.MapsLive do
{:noreply, socket}
end
@impl true
def handle_event("set-default-scope", %{"id" => id}, socket) do
send_update(LiveSelect.Component, options: ["wormholes", "stargates", "none", "all"], id: id)
{:noreply, socket}
end
def handle_event("generate-map-api-key", _params, socket) do
new_api_key = UUID.uuid4()
@@ -257,27 +254,25 @@ defmodule WandererAppWeb.MapsLive do
@impl true
def handle_event(
"live_select_change",
%{"id" => id, "text" => text} = _change_event,
%{"id" => id, "text" => _text} = _change_event,
socket
) do
options =
if text == "" do
socket.assigns.scopes
else
socket.assigns.scopes
end
send_update(LiveSelect.Component, options: options, id: id)
# This handler is for ACL live_select component
send_update(LiveSelect.Component, options: socket.assigns.acls, id: id)
{:noreply, socket}
end
def handle_event("validate", %{"form" => form} = _params, socket) do
# Process scopes from checkbox form data
scopes = parse_scopes_from_form(form)
form =
AshPhoenix.Form.validate(
socket.assigns.form,
form
|> Map.put("acls", form["acls"] || [])
|> Map.put("scopes", scopes)
|> Map.put(
"only_tracked_characters",
(form["only_tracked_characters"] || "false") |> String.to_existing_atom()
@@ -293,15 +288,10 @@ defmodule WandererAppWeb.MapsLive do
%{assigns: %{current_user: current_user}} = socket
)
when not is_nil(current_user) do
scope =
form
|> Map.get("scope")
|> case do
"" -> "wormholes"
scope -> scope
end
# Process scopes from checkbox form data
scopes = parse_scopes_from_form(form)
form = form |> Map.put("scope", scope)
form = form |> Map.put("scopes", scopes)
case WandererApp.Api.Map.new(form) do
{:ok, new_map} ->
@@ -426,18 +416,13 @@ defmodule WandererAppWeb.MapsLive do
# Successfully found the map, proceed with loading and updating
{:ok, map_with_acls} = Ash.load(map, :acls)
scope =
form
|> Map.get("scope")
|> case do
"" -> "wormholes"
scope -> scope
end
# Process scopes from checkbox form data
scopes = parse_scopes_from_form(form)
form =
form
|> Map.put("acls", form["acls"] || [])
|> Map.put("scope", scope)
|> Map.put("scopes", scopes)
|> Map.put(
"only_tracked_characters",
(form["only_tracked_characters"] || "false") |> String.to_existing_atom()
@@ -820,4 +805,74 @@ defmodule WandererAppWeb.MapsLive do
map
|> Map.put(:acls, acls |> Enum.map(&map_acl/1))
end
defp available_scopes do
[
%{value: "wormholes", label: "Wormholes", description: "J-space systems"},
%{value: "hi", label: "High-Sec", description: "Security 0.5 - 1.0"},
%{value: "low", label: "Low-Sec", description: "Security 0.1 - 0.4"},
%{value: "null", label: "Null-Sec", description: "Security 0.0 and below"},
%{value: "pochven", label: "Pochven", description: "Triglavian space"}
]
end
# Auto-initialize scopes from legacy scope setting if scopes is empty/nil
defp maybe_initialize_scopes_from_legacy(%{scopes: scopes} = map)
when is_list(scopes) and scopes != [] do
# Scopes already set, don't override
map
end
defp maybe_initialize_scopes_from_legacy(%{scope: scope} = map) do
# Convert legacy scope to new scopes format
scopes = legacy_scope_to_scopes(scope)
Map.put(map, :scopes, scopes)
end
defp maybe_initialize_scopes_from_legacy(map) do
# No scope field, default to wormholes
Map.put(map, :scopes, [:wormholes])
end
# Convert legacy scope atom to new scopes list
defp legacy_scope_to_scopes(:wormholes), do: [:wormholes]
defp legacy_scope_to_scopes(:stargates), do: [:hi, :low, :null]
defp legacy_scope_to_scopes(:none), do: []
defp legacy_scope_to_scopes(:all), do: [:wormholes, :hi, :low, :null, :pochven]
defp legacy_scope_to_scopes(_), do: [:wormholes]
defp parse_scopes_from_form(form) do
# Extract selected scopes from form data
# Form sends scopes as "scopes" => %{"wormholes" => "true", "hi" => "true", ...}
form
|> Map.get("scopes", %{})
|> case do
scopes when is_map(scopes) ->
scopes
|> Enum.filter(fn {_key, value} -> value == "true" end)
|> Enum.map(fn {key, _value} -> String.to_existing_atom(key) end)
scopes when is_list(scopes) ->
# Already a list of atoms/strings
scopes
|> Enum.map(fn
scope when is_atom(scope) -> scope
scope when is_binary(scope) -> String.to_existing_atom(scope)
end)
_ ->
[]
end
end
# Helper function to get current scopes from form for checkbox state
def get_current_scopes(form) do
scopes = Phoenix.HTML.Form.input_value(form, :scopes) || []
scopes
|> Enum.map(fn
scope when is_atom(scope) -> Atom.to_string(scope)
scope when is_binary(scope) -> scope
end)
end
end

View File

@@ -151,15 +151,66 @@
placeholder="Select a map owner"
options={Enum.map(@characters, fn character -> {character.label, character.id} end)}
/>
<.input
type="select"
field={f[:scope]}
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
wrapper_class="mt-2"
label="Map scope"
placeholder="Select a map scope"
options={Enum.map(@scopes, fn scope -> {scope, scope} end)}
/>
<!-- Map Scopes Section -->
<div class="mt-2 border border-dashed border-stone-600 rounded p-3">
<p class="text-xs text-stone-400 mb-2">
Select which space types to automatically track on the map
</p>
<div class="grid grid-cols-2 gap-1">
<%= for scope_option <- @available_scopes do %>
<% is_checked = scope_option.value in (get_current_scopes(f) || []) %>
<label class="flex items-center gap-2 cursor-pointer py-1 px-2 rounded hover:bg-stone-800">
<div class="flex items-center">
<div
class={[
"checkboxRoot sizeM p-checkbox p-component",
if(is_checked, do: "p-highlight", else: "")
]}
data-p-highlight={is_checked}
data-p-disabled="false"
data-pc-name="checkbox"
data-pc-section="root"
>
<input
type="checkbox"
name={"form[scopes][#{scope_option.value}]"}
value="true"
checked={is_checked}
class="p-checkbox-input"
aria-invalid="false"
data-pc-section="input"
/>
<div
class="p-checkbox-box"
data-p-highlight={is_checked}
data-p-disabled="false"
data-pc-section="box"
>
<svg
:if={is_checked}
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="p-icon p-checkbox-icon"
aria-hidden="true"
data-pc-section="icon"
>
<path
d="M4.86199 11.5948C4.78717 11.5923 4.71366 11.5745 4.64596 11.5426C4.57826 11.5107 4.51779 11.4652 4.46827 11.4091L0.753985 7.69483C0.683167 7.64891 0.623706 7.58751 0.580092 7.51525C0.536478 7.44299 0.509851 7.36177 0.502221 7.27771C0.49459 7.19366 0.506156 7.10897 0.536046 7.03004C0.565935 6.95111 0.613367 6.88 0.674759 6.82208C0.736151 6.76416 0.8099 6.72095 0.890436 6.69571C0.970973 6.67046 1.05619 6.66385 1.13966 6.67635C1.22313 6.68886 1.30266 6.72017 1.37226 6.76792C1.44186 6.81567 1.4997 6.8786 1.54141 6.95197L4.86199 10.2503L12.6397 2.49483C12.7444 2.42694 12.8689 2.39617 12.9932 2.40745C13.1174 2.41873 13.2343 2.47141 13.3251 2.55705C13.4159 2.64268 13.4753 2.75632 13.4938 2.87973C13.5123 3.00315 13.4888 3.1292 13.4271 3.23768L5.2557 11.4091C5.20618 11.4652 5.14571 11.5107 5.07801 11.5426C5.01031 11.5745 4.9368 11.5923 4.86199 11.5948Z"
fill="currentColor"
>
</path>
</svg>
</div>
</div>
</div>
<span class="text-xs select-none">{scope_option.label}</span>
</label>
<% end %>
</div>
</div>
<.input
type="checkbox"
field={f[:only_tracked_characters]}
@@ -170,7 +221,10 @@
type="checkbox"
field={f[:create_default_acl]}
label="Create default access list"
checked={Phoenix.HTML.Form.normalize_value("checkbox", f[:create_default_acl].value) == true or is_nil(f[:create_default_acl].value)}
checked={
Phoenix.HTML.Form.normalize_value("checkbox", f[:create_default_acl].value) == true or
is_nil(f[:create_default_acl].value)
}
/>
<.live_select
field={f[:acls]}

View File

@@ -59,7 +59,7 @@
rows={@transactions}
class="!max-h-[40vh] !overflow-y-auto"
>
<:col :let={transaction}>
<:col :let={_transaction}>
<div class=" text-22">
<.icon name="hero-credit-card-solid" class="h-5 w-5" />
</div>
@@ -145,7 +145,7 @@
rows={@invoices}
class="!max-h-[40vh] !overflow-y-auto"
>
<:col :let={invoice}>
<:col :let={_invoice}>
<div class=" text-22">
Map subscription
</div>

View File

@@ -5,8 +5,6 @@ defmodule WandererAppWeb.OpenApiV1Spec do
@behaviour OpenApiSpex.OpenApi
alias OpenApiSpex.{OpenApi, Info, Server, Components}
@impl OpenApiSpex.OpenApi
def spec do
# This is called by the modify_open_api option in the router

View File

@@ -220,7 +220,7 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
defp validate_params(_params, _max_length, _max_depth, _current_depth), do: :ok
defp validate_param_value(key, value, max_length, max_depth, current_depth)
defp validate_param_value(key, value, max_length, _max_depth, _current_depth)
when is_binary(value) do
cond do
String.length(value) > max_length ->

View File

@@ -94,7 +94,7 @@ defmodule WandererAppWeb.Plugs.ResponseSanitizer do
case Application.get_env(:wanderer_app, :environment) do
:dev ->
nonce = generate_nonce()
conn = put_private(conn, :csp_nonce, nonce)
_conn = put_private(conn, :csp_nonce, nonce)
base_policy
|> Enum.map(fn directive ->

View File

@@ -82,7 +82,8 @@ defmodule WandererAppWeb.Router do
"allow-modals",
"allow-same-origin",
"allow-downloads",
"allow-popups"
"allow-popups",
"allow-popups-to-escape-sandbox"
]
pipeline :admin_bauth do

View File

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

View File

@@ -0,0 +1,60 @@
defmodule WandererApp.Repo.Migrations.AddMapScopesExtensions1 do
@moduledoc """
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
execute("ALTER FUNCTION ash_raise_error(jsonb) STABLE;")
execute("ALTER FUNCTION ash_raise_error(jsonb, ANYCOMPATIBLE) STABLE")
execute("""
CREATE OR REPLACE FUNCTION uuid_generate_v7()
RETURNS UUID
AS $$
DECLARE
timestamp TIMESTAMPTZ;
microseconds INT;
BEGIN
timestamp = clock_timestamp();
microseconds = (cast(extract(microseconds FROM timestamp)::INT - (floor(extract(milliseconds FROM timestamp))::INT * 1000) AS DOUBLE PRECISION) * 4.096)::INT;
RETURN encode(
set_byte(
set_byte(
overlay(uuid_send(gen_random_uuid()) placing substring(int8send(floor(extract(epoch FROM timestamp) * 1000)::BIGINT) FROM 3) FROM 1 FOR 6
),
6, (b'0111' || (microseconds >> 8)::bit(4))::bit(8)::int
),
7, microseconds::bit(8)::int
),
'hex')::UUID;
END
$$
LANGUAGE PLPGSQL
SET search_path = ''
VOLATILE;
""")
execute("""
CREATE OR REPLACE FUNCTION timestamp_from_uuid_v7(_uuid uuid)
RETURNS TIMESTAMP WITHOUT TIME ZONE
AS $$
SELECT to_timestamp(('x0000' || substr(_uuid::TEXT, 1, 8) || substr(_uuid::TEXT, 10, 4))::BIT(64)::BIGINT::NUMERIC / 1000);
$$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE PARALLEL SAFE STRICT;
""")
end
def down do
# Uncomment this if you actually want to uninstall the extensions
# when this migration is rolled back:
execute("ALTER FUNCTION ash_raise_error(jsonb) VOLATILE;")
execute("ALTER FUNCTION ash_raise_error(jsonb, ANYCOMPATIBLE) VOLATILE")
end
end

View File

@@ -0,0 +1,36 @@
defmodule WandererApp.Repo.Migrations.AddMapScopes do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:maps_v1) do
add :scopes, {:array, :text}, default: fragment("'{wormholes}'")
end
create_if_not_exists unique_index(:maps_v1, [:public_api_key],
name: "maps_v1_unique_public_api_key_index"
)
drop_if_exists index(:map_system_v1, [:map_id], name: "map_system_v1_map_id_visible_index")
end
def down do
create_if_not_exists index(:map_system_v1, [:map_id],
name: "map_system_v1_map_id_visible_index",
where: "visible = true"
)
drop_if_exists unique_index(:maps_v1, [:public_api_key],
name: "maps_v1_unique_public_api_key_index"
)
alter table(:maps_v1) do
remove :scopes
end
end
end

View File

@@ -0,0 +1,21 @@
defmodule WandererApp.Repo.Migrations.AddMapScopesDefault do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:maps_v1) do
modify :scopes, {:array, :text}, default: nil
end
end
def down do
alter table(:maps_v1) do
modify :scopes, {:array, :text}, default: []
end
end
end

View File

@@ -1,5 +1,5 @@
{
"ash_functions_version": 4,
"ash_functions_version": 5,
"installed": [
"ash-functions"
]

View File

@@ -0,0 +1,273 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "solar_system_id",
"type": "bigint"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "custom_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "tag",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "temporary_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "labels",
"type": "text"
},
{
"allow_nil?": true,
"default": "0",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "status",
"type": "bigint"
},
{
"allow_nil?": true,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "visible",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "locked",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "0",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "position_x",
"type": "bigint"
},
{
"allow_nil?": true,
"default": "0",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "position_y",
"type": "bigint"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "added_at",
"type": "utc_datetime"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "linked_sig_eve_id",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "map_system_v1_map_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": null,
"table": "maps_v1"
},
"scale": null,
"size": null,
"source": "map_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "09F2BB9547840BDD02F03DC583DB070F6E9C20DFA88A11E1909A1474DBD618E1",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "map_system_v1_map_solar_system_id_index",
"keys": [
{
"type": "atom",
"value": "map_id"
},
{
"type": "atom",
"value": "solar_system_id"
}
],
"name": "map_solar_system_id",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.WandererApp.Repo",
"schema": null,
"table": "map_system_v1"
}

View File

@@ -0,0 +1,277 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "personal_note",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "public_api_key",
"type": "text"
},
{
"allow_nil?": true,
"default": "[]",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hubs",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "\"wormholes\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "scope",
"type": "text"
},
{
"allow_nil?": true,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "deleted",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "only_tracked_characters",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "options",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "webhooks_enabled",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "sse_enabled",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "[]",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "scopes",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "maps_v1_owner_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": null,
"table": "character_v1"
},
"scale": null,
"size": null,
"source": "owner_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "3D9473E444C93E264B56CF72A88A65B07EAFF29061F29883CAC32E2460C7EC81",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "maps_v1_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "maps_v1_unique_public_api_key_index",
"keys": [
{
"type": "atom",
"value": "public_api_key"
}
],
"name": "unique_public_api_key",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.WandererApp.Repo",
"schema": null,
"table": "maps_v1"
}

View File

@@ -0,0 +1,277 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "personal_note",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "public_api_key",
"type": "text"
},
{
"allow_nil?": true,
"default": "[]",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "hubs",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "\"wormholes\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "scope",
"type": "text"
},
{
"allow_nil?": true,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "deleted",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "only_tracked_characters",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "options",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "webhooks_enabled",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "sse_enabled",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "scopes",
"type": [
"array",
"text"
]
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "maps_v1_owner_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": null,
"table": "character_v1"
},
"scale": null,
"size": null,
"source": "owner_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "3DAC7357D834955BDE6456A48FDA7610EE7C2D9C6AB17704563807682F99EBAB",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "maps_v1_unique_public_api_key_index",
"keys": [
{
"type": "atom",
"value": "public_api_key"
}
],
"name": "unique_public_api_key",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "maps_v1_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.WandererApp.Repo",
"schema": null,
"table": "maps_v1"
}

View File

@@ -0,0 +1,454 @@
defmodule WandererApp.AclMemberCacheInvalidationTest do
@moduledoc """
Integration tests for ACL member cache invalidation.
These tests verify that when ACL members are added, updated, or deleted,
the map_characters cache is properly invalidated so that:
1. New characters appear in the tracking page immediately after page reload
2. The cache doesn't serve stale data after ACL changes
3. Both API controller and LiveView paths properly invalidate the cache
Related files:
- lib/wanderer_app/maps.ex (get_map_characters cache)
- lib/wanderer_app_web/controllers/access_list_member_api_controller.ex
- lib/wanderer_app_web/live/access_lists/access_lists_live.ex
- lib/wanderer_app_web/live/characters/characters_tracking_live.ex
"""
use WandererApp.IntegrationCase, async: false
import Mox
import WandererApp.MapTestHelpers
import WandererAppWeb.Factory
require Ash.Query
setup :verify_on_exit!
@test_character_eve_id_1 4_100_000_001
@test_character_eve_id_2 4_100_000_002
@test_character_eve_id_3 4_100_000_003
setup do
# Setup system static info cache for test systems
setup_system_static_info_cache()
# Setup DDRT (R-tree) mock stubs for system positioning
setup_ddrt_mocks()
# Create map owner user and character
owner_user =
create_user(%{
name: "ACL Cache Test Owner",
hash: "acl_cache_owner_#{:rand.uniform(1_000_000)}"
})
owner_character =
create_character(%{
eve_id: "#{@test_character_eve_id_1}",
name: "ACL Cache Test Owner Character",
user_id: owner_user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Create a second user with a character that will be added to ACL
member_user =
create_user(%{
name: "ACL Cache Test Member",
hash: "acl_cache_member_#{:rand.uniform(1_000_000)}"
})
member_character =
create_character(%{
eve_id: "#{@test_character_eve_id_2}",
name: "ACL Cache Test Member Character",
user_id: member_user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Create a third character for additional tests
member_character2 =
create_character(%{
eve_id: "#{@test_character_eve_id_3}",
name: "ACL Cache Test Member Character 2",
user_id: member_user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Create test map owned by first character
map =
create_map(%{
name: "ACL Cache Test Map",
slug: "acl-cache-test-#{:rand.uniform(1_000_000)}",
owner_id: owner_character.id,
scope: :all,
only_tracked_characters: false
})
# Create an access list owned by the owner character
acl = create_access_list(owner_character.id, %{name: "Test ACL for Cache"})
# Associate the ACL with the map
create_map_access_list(map.id, acl.id)
on_exit(fn ->
cleanup_test_data(map.id)
cleanup_character_caches(map.id, owner_character.id)
cleanup_character_caches(map.id, member_character.id)
cleanup_character_caches(map.id, member_character2.id)
# Clean up map_characters cache
WandererApp.Cache.delete("map_characters-#{map.id}")
end)
{:ok,
owner_user: owner_user,
owner_character: owner_character,
member_user: member_user,
member_character: member_character,
member_character2: member_character2,
map: map,
acl: acl}
end
describe "cache invalidation when ACL members change" do
@tag :integration
test "adding a character member and broadcasting invalidates map_characters cache", %{
map: map,
acl: acl,
member_character: member_character
} do
cache_key = "map_characters-#{map.id}"
# Warm up the cache by loading characters
{:ok, _} = WandererApp.Maps.load_characters(map, member_character.user_id)
# Verify cache is populated
cached_data = WandererApp.Cache.lookup!(cache_key)
assert not is_nil(cached_data), "Cache should be populated after load_characters"
# Verify member character is NOT in the cached data (not yet added to ACL)
refute member_character.eve_id in cached_data.map_member_eve_ids,
"Member character should not be in cache before being added to ACL"
# Add member directly to database (simulating what API controller does)
create_access_list_member(acl.id, %{
name: member_character.name,
role: "member",
eve_character_id: member_character.eve_id
})
# Simulate what the API controller does - broadcast ACL updated
# This triggers the cache invalidation
invalidate_map_characters_cache_for_acl(acl.id)
# Verify cache was invalidated
cached_data_after = WandererApp.Cache.lookup!(cache_key)
assert is_nil(cached_data_after), "Cache should be invalidated after adding member"
# Reload characters - should get fresh data with new member
{:ok, %{characters: characters}} =
WandererApp.Maps.load_characters(map, member_character.user_id)
# Verify member character is now available
character_eve_ids = Enum.map(characters, & &1.eve_id)
assert member_character.eve_id in character_eve_ids,
"Member character should be available after being added to ACL"
end
@tag :integration
test "adding a corporation member and broadcasting invalidates map_characters cache", %{
map: map,
acl: acl,
member_character: member_character
} do
cache_key = "map_characters-#{map.id}"
# Warm up the cache
{:ok, _} = WandererApp.Maps.load_characters(map, member_character.user_id)
# Verify cache is populated
cached_data = WandererApp.Cache.lookup!(cache_key)
assert not is_nil(cached_data)
# Add corporation member
create_access_list_member(acl.id, %{
name: "Test Corporation",
role: "viewer",
eve_corporation_id: "98000001"
})
# Simulate cache invalidation
invalidate_map_characters_cache_for_acl(acl.id)
# Verify cache was invalidated
cached_data_after = WandererApp.Cache.lookup!(cache_key)
assert is_nil(cached_data_after), "Cache should be invalidated after adding corporation member"
end
@tag :integration
test "adding an alliance member and broadcasting invalidates map_characters cache", %{
map: map,
acl: acl,
member_character: member_character
} do
cache_key = "map_characters-#{map.id}"
# Warm up the cache
{:ok, _} = WandererApp.Maps.load_characters(map, member_character.user_id)
# Verify cache is populated
cached_data = WandererApp.Cache.lookup!(cache_key)
assert not is_nil(cached_data)
# Add alliance member
create_access_list_member(acl.id, %{
name: "Test Alliance",
role: "viewer",
eve_alliance_id: "99000001"
})
# Simulate cache invalidation
invalidate_map_characters_cache_for_acl(acl.id)
# Verify cache was invalidated
cached_data_after = WandererApp.Cache.lookup!(cache_key)
assert is_nil(cached_data_after), "Cache should be invalidated after adding alliance member"
end
@tag :integration
test "deleting a member and broadcasting invalidates map_characters cache", %{
map: map,
acl: acl,
member_character: member_character
} do
cache_key = "map_characters-#{map.id}"
# First add a member
member =
create_access_list_member(acl.id, %{
name: member_character.name,
role: "viewer",
eve_character_id: member_character.eve_id
})
# Clear cache and warm it up again
WandererApp.Cache.delete(cache_key)
{:ok, _} = WandererApp.Maps.load_characters(map, member_character.user_id)
# Verify cache is populated and member is included
cached_data = WandererApp.Cache.lookup!(cache_key)
assert not is_nil(cached_data)
assert member_character.eve_id in cached_data.map_member_eve_ids,
"Member should be in cache before deletion"
# Delete the member
WandererApp.Api.AccessListMember.destroy!(member)
# Simulate cache invalidation
invalidate_map_characters_cache_for_acl(acl.id)
# Verify cache was invalidated
cached_data_after = WandererApp.Cache.lookup!(cache_key)
assert is_nil(cached_data_after), "Cache should be invalidated after deleting member"
# Reload and verify member is no longer in the list
{:ok, %{characters: characters}} =
WandererApp.Maps.load_characters(map, member_character.user_id)
character_eve_ids = Enum.map(characters, & &1.eve_id)
refute member_character.eve_id in character_eve_ids,
"Member character should not be available after being removed from ACL"
end
end
describe "cache TTL functionality" do
@tag :integration
test "map_characters cache has TTL and can be invalidated", %{
map: map,
member_character: member_character
} do
cache_key = "map_characters-#{map.id}"
# Warm up the cache
{:ok, _} = WandererApp.Maps.load_characters(map, member_character.user_id)
# Verify cache is populated
cached_data = WandererApp.Cache.lookup!(cache_key)
assert not is_nil(cached_data), "Cache should be populated"
# Note: We can't easily test the 5-minute TTL in a unit test,
# but we can verify the cache entry exists and can be manually invalidated
WandererApp.Cache.delete(cache_key)
# After deletion, cache should be nil
cached_data_after = WandererApp.Cache.lookup!(cache_key)
assert is_nil(cached_data_after), "Cache should be nil after deletion"
end
end
describe "multiple maps with same ACL" do
@tag :integration
test "adding member invalidates cache for all maps using the ACL", %{
map: map1,
owner_character: owner_character,
member_character: member_character,
acl: acl
} do
# Create a second map that also uses the same ACL
map2 =
create_map(%{
name: "ACL Cache Test Map 2",
slug: "acl-cache-test-2-#{:rand.uniform(1_000_000)}",
owner_id: owner_character.id,
scope: :all,
only_tracked_characters: false
})
# Associate the same ACL with the second map
create_map_access_list(map2.id, acl.id)
cache_key1 = "map_characters-#{map1.id}"
cache_key2 = "map_characters-#{map2.id}"
# Warm up both caches
{:ok, _} = WandererApp.Maps.load_characters(map1, member_character.user_id)
{:ok, _} = WandererApp.Maps.load_characters(map2, member_character.user_id)
# Verify both caches are populated
cached_data1 = WandererApp.Cache.lookup!(cache_key1)
cached_data2 = WandererApp.Cache.lookup!(cache_key2)
assert not is_nil(cached_data1), "First map cache should be populated"
assert not is_nil(cached_data2), "Second map cache should be populated"
# Add member
create_access_list_member(acl.id, %{
name: member_character.name,
role: "member",
eve_character_id: member_character.eve_id
})
# Simulate cache invalidation (what the API controller does)
invalidate_map_characters_cache_for_acl(acl.id)
# Verify both caches were invalidated
cached_data1_after = WandererApp.Cache.lookup!(cache_key1)
cached_data2_after = WandererApp.Cache.lookup!(cache_key2)
assert is_nil(cached_data1_after),
"First map cache should be invalidated when member is added to shared ACL"
assert is_nil(cached_data2_after),
"Second map cache should be invalidated when member is added to shared ACL"
# Cleanup
on_exit(fn ->
cleanup_test_data(map2.id)
WandererApp.Cache.delete(cache_key2)
end)
end
end
describe "load_characters returns fresh data after cache invalidation" do
@tag :integration
test "newly added member appears in load_characters result", %{
map: map,
acl: acl,
member_user: member_user,
member_character: member_character
} do
# Initially, member character should not be available (not in ACL)
{:ok, %{characters: initial_characters}} =
WandererApp.Maps.load_characters(map, member_user.id)
initial_eve_ids = Enum.map(initial_characters, & &1.eve_id)
refute member_character.eve_id in initial_eve_ids,
"Member character should not be available before being added to ACL"
# Add member to ACL
create_access_list_member(acl.id, %{
name: member_character.name,
role: "member",
eve_character_id: member_character.eve_id
})
# Simulate cache invalidation
invalidate_map_characters_cache_for_acl(acl.id)
# Now load_characters should return the new member
{:ok, %{characters: updated_characters}} =
WandererApp.Maps.load_characters(map, member_user.id)
updated_eve_ids = Enum.map(updated_characters, & &1.eve_id)
assert member_character.eve_id in updated_eve_ids,
"Member character should be available after being added to ACL and cache invalidation"
end
@tag :integration
test "removed member disappears from load_characters result", %{
map: map,
acl: acl,
member_user: member_user,
member_character: member_character
} do
# Add member to ACL first
member =
create_access_list_member(acl.id, %{
name: member_character.name,
role: "member",
eve_character_id: member_character.eve_id
})
# Clear cache to get fresh data
WandererApp.Cache.delete("map_characters-#{map.id}")
# Member should be available
{:ok, %{characters: characters_with_member}} =
WandererApp.Maps.load_characters(map, member_user.id)
eve_ids_with_member = Enum.map(characters_with_member, & &1.eve_id)
assert member_character.eve_id in eve_ids_with_member,
"Member character should be available when in ACL"
# Remove member
WandererApp.Api.AccessListMember.destroy!(member)
# Simulate cache invalidation
invalidate_map_characters_cache_for_acl(acl.id)
# Member should no longer be available
{:ok, %{characters: characters_without_member}} =
WandererApp.Maps.load_characters(map, member_user.id)
eve_ids_without_member = Enum.map(characters_without_member, & &1.eve_id)
refute member_character.eve_id in eve_ids_without_member,
"Member character should not be available after being removed from ACL"
end
end
# Helper function that simulates what the API controller does
# This is the same logic as in AccessListMemberAPIController.invalidate_map_characters_cache/1
defp invalidate_map_characters_cache_for_acl(acl_id) do
case Ash.read(
WandererApp.Api.MapAccessList
|> Ash.Query.for_read(:read_by_acl, %{acl_id: acl_id})
) do
{:ok, map_acls} ->
Enum.each(map_acls, fn %{map_id: map_id} ->
WandererApp.Cache.delete("map_characters-#{map_id}")
end)
{:error, _error} ->
:ok
end
end
end

View File

@@ -285,6 +285,7 @@ defmodule WandererAppWeb.MapAccessListAPIControllerTest do
test "returns 404 for non-existent ACL", %{conn: _conn} do
conn = build_conn()
update_params = %{
"acl" => %{
"name" => "Updated Name"

View File

@@ -0,0 +1,427 @@
defmodule WandererApp.CharactersTrackingLiveTest do
@moduledoc """
Integration tests for CharactersTrackingLive tracking functionality.
These tests verify the fix for the bug where enabling tracking via the
/tracking/:slug page (CharactersTrackingLive) only updated the database
but did NOT set the tracking_start_time cache key, causing characters
to not actually be tracked on the map.
The fix ensures CharactersTrackingLive uses TrackingUtils.update_tracking()
which properly sets:
1. Database tracking flag (MapCharacterSettings.tracked = true)
2. Runtime cache key (character:<id>:map:<map_id>:tracking_start_time)
Related files:
- lib/wanderer_app_web/live/characters/characters_tracking_live.ex
- lib/wanderer_app/character/tracking_utils.ex
- lib/wanderer_app/map.ex (get_tracked_character_ids)
"""
use WandererApp.IntegrationCase, async: false
import Mox
import WandererApp.MapTestHelpers
setup :verify_on_exit!
@test_character_eve_id_1 3_100_000_001
@test_character_eve_id_2 3_100_000_002
setup do
# Setup system static info cache for test systems
setup_system_static_info_cache()
# Setup DDRT (R-tree) mock stubs for system positioning
setup_ddrt_mocks()
# Create test user
user =
create_user(%{
name: "Tracking Live Test User",
hash: "tracking_live_test_hash_#{:rand.uniform(1_000_000)}"
})
# Create first test character
character1 =
create_character(%{
eve_id: "#{@test_character_eve_id_1}",
name: "Tracking Live Test Character 1",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Create second test character (to test multiple character tracking)
character2 =
create_character(%{
eve_id: "#{@test_character_eve_id_2}",
name: "Tracking Live Test Character 2",
user_id: user.id,
scopes: "esi-location.read_location.v1 esi-location.read_ship_type.v1",
tracking_pool: "default"
})
# Create test map owned by first character
map =
create_map(%{
name: "Track Live Test",
slug: "tracking-live-test-#{:rand.uniform(1_000_000)}",
owner_id: character1.id,
scope: :all,
only_tracked_characters: false
})
on_exit(fn ->
cleanup_test_data(map.id)
cleanup_character_caches(map.id, character1.id)
cleanup_character_caches(map.id, character2.id)
cleanup_tracking_caches(character1.id, map.id)
cleanup_tracking_caches(character2.id, map.id)
end)
{:ok, user: user, character1: character1, character2: character2, map: map}
end
describe "TrackingUtils.update_tracking sets tracking_start_time cache key" do
@tag :integration
test "enabling tracking via update_tracking sets tracking_start_time immediately", %{
map: map,
character1: character,
user: user
} do
# Verify no tracking_start_time exists initially
tracking_key = "character:#{character.id}:map:#{map.id}:tracking_start_time"
{:ok, initial_value} = WandererApp.Cache.lookup(tracking_key)
assert is_nil(initial_value), "tracking_start_time should not exist initially"
# Call update_tracking (this is what CharactersTrackingLive now does)
# The eve_id is passed as a string which is how it comes from the UI
result =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
true,
self(),
false
)
assert {:ok, _tracking_data, _event} = result
# Verify tracking_start_time is now set
{:ok, tracking_start_time} = WandererApp.Cache.lookup(tracking_key)
assert not is_nil(tracking_start_time),
"tracking_start_time should be set immediately after enabling tracking"
assert %DateTime{} = tracking_start_time,
"tracking_start_time should be a DateTime"
# Verify the time is recent (within last 5 seconds)
time_diff = DateTime.diff(DateTime.utc_now(), tracking_start_time, :second)
assert time_diff >= 0 and time_diff < 5, "tracking_start_time should be recent"
end
@tag :integration
test "enabling tracking for multiple characters sets all tracking_start_times", %{
map: map,
character1: character1,
character2: character2,
user: user
} do
# Verify no tracking_start_time exists initially for either character
tracking_key1 = "character:#{character1.id}:map:#{map.id}:tracking_start_time"
tracking_key2 = "character:#{character2.id}:map:#{map.id}:tracking_start_time"
{:ok, initial_value1} = WandererApp.Cache.lookup(tracking_key1)
{:ok, initial_value2} = WandererApp.Cache.lookup(tracking_key2)
assert is_nil(initial_value1), "tracking_start_time should not exist initially for char1"
assert is_nil(initial_value2), "tracking_start_time should not exist initially for char2"
# Enable tracking for first character
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character1.eve_id,
user.id,
true,
self(),
false
)
# Enable tracking for second character
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character2.eve_id,
user.id,
true,
self(),
false
)
# Verify both characters have tracking_start_time set
{:ok, tracking_time1} = WandererApp.Cache.lookup(tracking_key1)
{:ok, tracking_time2} = WandererApp.Cache.lookup(tracking_key2)
assert not is_nil(tracking_time1),
"Character 1 should have tracking_start_time set"
assert not is_nil(tracking_time2),
"Character 2 should have tracking_start_time set"
end
@tag :integration
test "disabling tracking via update_tracking works correctly", %{
map: map,
character1: character,
user: user
} do
# First enable tracking
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
true,
self(),
false
)
# Verify tracking is enabled
{:ok, settings} =
WandererApp.MapCharacterSettingsRepo.get(map.id, character.id)
assert settings.tracked == true, "Character should be tracked after enabling"
# Now disable tracking
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
false,
self(),
false
)
# Verify tracking is disabled in database
{:ok, updated_settings} =
WandererApp.MapCharacterSettingsRepo.get(map.id, character.id)
assert updated_settings.tracked == false, "Character should be untracked after disabling"
end
@tag :integration
test "toggle tracking on and off maintains correct state", %{
map: map,
character1: character,
user: user
} do
tracking_key = "character:#{character.id}:map:#{map.id}:tracking_start_time"
# Initial state: not tracked
{:ok, initial_time} = WandererApp.Cache.lookup(tracking_key)
assert is_nil(initial_time)
# Toggle ON
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
true,
self(),
false
)
{:ok, time_after_on} = WandererApp.Cache.lookup(tracking_key)
assert not is_nil(time_after_on), "tracking_start_time should be set after toggle ON"
{:ok, settings_on} = WandererApp.MapCharacterSettingsRepo.get(map.id, character.id)
assert settings_on.tracked == true
# Toggle OFF
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
false,
self(),
false
)
{:ok, settings_off} = WandererApp.MapCharacterSettingsRepo.get(map.id, character.id)
assert settings_off.tracked == false
# Toggle ON again
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
true,
self(),
false
)
{:ok, time_after_second_on} = WandererApp.Cache.lookup(tracking_key)
assert not is_nil(time_after_second_on),
"tracking_start_time should be set after second toggle ON"
{:ok, settings_on_again} = WandererApp.MapCharacterSettingsRepo.get(map.id, character.id)
assert settings_on_again.tracked == true
end
end
describe "Database and cache consistency" do
@tag :integration
test "tracking state is consistent between database and cache", %{
map: map,
character1: character,
user: user
} do
tracking_key = "character:#{character.id}:map:#{map.id}:tracking_start_time"
# Enable tracking
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
true,
self(),
false
)
# Check database state
{:ok, db_settings} = WandererApp.MapCharacterSettingsRepo.get(map.id, character.id)
assert db_settings.tracked == true, "Database should show tracked=true"
# Check cache state
{:ok, cache_time} = WandererApp.Cache.lookup(tracking_key)
assert not is_nil(cache_time), "Cache should have tracking_start_time"
# Both should indicate the character is being tracked
end
@tag :integration
test "stale location caches are cleared when tracking is re-enabled", %{
map: map,
character1: character,
user: user
} do
# Set up stale location caches (simulating previous tracking session)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:solar_system_id",
30_000_142
)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:station_id",
60_003_760
)
WandererApp.Cache.insert(
"map:#{map.id}:character:#{character.id}:structure_id",
1_000_000_001
)
# Verify stale caches exist
{:ok, stale_system} =
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:solar_system_id")
assert stale_system == 30_000_142
# Enable tracking (this should clear stale location caches)
{:ok, _, _} =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
true,
self(),
false
)
# Verify stale caches are cleared
{:ok, cleared_system} =
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:solar_system_id")
{:ok, cleared_station} =
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:station_id")
{:ok, cleared_structure} =
WandererApp.Cache.lookup("map:#{map.id}:character:#{character.id}:structure_id")
assert is_nil(cleared_system), "solar_system_id cache should be cleared"
assert is_nil(cleared_station), "station_id cache should be cleared"
assert is_nil(cleared_structure), "structure_id cache should be cleared"
end
end
describe "Error handling" do
@tag :integration
test "update_tracking returns error for invalid character", %{
map: map,
user: user
} do
# Try to enable tracking for a non-existent character
result =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
"999999999999",
user.id,
true,
self(),
false
)
assert {:error, _reason} = result
end
@tag :integration
test "update_tracking handles nil caller_pid gracefully", %{
map: map,
character1: character,
user: user
} do
# Calling with nil caller_pid should return an error
result =
WandererApp.Character.TrackingUtils.update_tracking(
map.id,
character.eve_id,
user.id,
true,
nil,
false
)
assert {:error, _reason} = result
end
end
# Helper function to cleanup tracking-specific caches
defp cleanup_tracking_caches(character_id, map_id) do
WandererApp.Cache.delete("character:#{character_id}:map:#{map_id}:tracking_start_time")
WandererApp.Cache.delete("#{character_id}:track_requested")
# Clean up presence subscription cache
WandererApp.Cache.delete("#{inspect(self())}_map_#{map_id}:character_#{character_id}:tracked")
# Clean up untrack queue
WandererApp.Cache.insert_or_update(
"character_untrack_queue",
[],
fn queue ->
Enum.reject(queue, fn {m_id, c_id} ->
m_id == map_id and c_id == character_id
end)
end
)
end
end

View File

@@ -34,7 +34,11 @@ defmodule WandererApp.Map.CharacterTrackingEnableTest do
setup_ddrt_mocks()
# Create test user
user = create_user(%{name: "Tracking Test User", hash: "tracking_test_hash_#{:rand.uniform(1_000_000)}"})
user =
create_user(%{
name: "Tracking Test User",
hash: "tracking_test_hash_#{:rand.uniform(1_000_000)}"
})
# Create test character with location tracking scopes
character =

View File

@@ -0,0 +1,315 @@
defmodule WandererApp.Map.MapScopeFilteringTest do
@moduledoc """
Integration tests for map scope filtering during character location tracking.
These tests verify that systems are correctly filtered based on map scope settings
when characters move between systems. The key scenarios tested:
1. Characters moving between systems with [:wormholes, :null] scopes:
- Wormhole systems should be added
- Null-sec systems should be added
- High-sec systems should NOT be added (filtered out)
- Low-sec systems should NOT be added (filtered out)
2. Wormhole border behavior:
- When a character jumps from wormhole to k-space, the wormhole should be added
- K-space border systems should only be added if they match the scopes
3. K-space only movement:
- Characters moving within k-space should only track systems matching scopes
- No "border system" behavior for k-space to k-space movement
Reference bug: Characters with [:wormholes, :null] scopes were getting
high-sec (0.6) and low-sec (0.4) systems added to the map when traveling.
"""
use WandererApp.DataCase
# System class constants (matching ConnectionsImpl)
@c1 1
@c2 2
@hs 7
@ls 8
@ns 9
# Test solar system IDs
# C1 wormhole
@wh_system_j100001 31_000_001
# C2 wormhole
@wh_system_j100002 31_000_002
# High-sec system (0.6)
@hs_system_halenan 30_000_001
# High-sec system (0.6)
@hs_system_mili 30_000_002
# Low-sec system (0.4)
@ls_system_halmah 30_000_100
# Null-sec system
@ns_system_geminate 30_000_200
setup do
# Setup system static info cache with both wormhole and k-space systems
setup_scope_test_systems()
:ok
end
# Setup system static info for scope testing
defp setup_scope_test_systems do
test_systems = %{
# C1 Wormhole
@wh_system_j100001 => %{
solar_system_id: @wh_system_j100001,
solar_system_name: "J100001",
solar_system_name_lc: "j100001",
region_id: 11_000_001,
constellation_id: 21_000_001,
region_name: "A-R00001",
constellation_name: "A-C00001",
system_class: @c1,
security: "-1.0",
type_description: "Class 1",
class_title: "C1",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: ["H121"],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# C2 Wormhole
@wh_system_j100002 => %{
solar_system_id: @wh_system_j100002,
solar_system_name: "J100002",
solar_system_name_lc: "j100002",
region_id: 11_000_001,
constellation_id: 21_000_001,
region_name: "A-R00001",
constellation_name: "A-C00001",
system_class: @c2,
security: "-1.0",
type_description: "Class 2",
class_title: "C2",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: ["D382", "L005"],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# High-sec system (Halenan 0.6)
@hs_system_halenan => %{
solar_system_id: @hs_system_halenan,
solar_system_name: "Halenan",
solar_system_name_lc: "halenan",
region_id: 10_000_067,
constellation_id: 20_000_901,
region_name: "Devoid",
constellation_name: "Devoid",
system_class: @hs,
security: "0.6",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# High-sec system (Mili 0.6)
@hs_system_mili => %{
solar_system_id: @hs_system_mili,
solar_system_name: "Mili",
solar_system_name_lc: "mili",
region_id: 10_000_067,
constellation_id: 20_000_901,
region_name: "Devoid",
constellation_name: "Devoid",
system_class: @hs,
security: "0.6",
type_description: "High Security",
class_title: "High Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Low-sec system (Halmah 0.4)
@ls_system_halmah => %{
solar_system_id: @ls_system_halmah,
solar_system_name: "Halmah",
solar_system_name_lc: "halmah",
region_id: 10_000_067,
constellation_id: 20_000_901,
region_name: "Devoid",
constellation_name: "Devoid",
system_class: @ls,
security: "0.4",
type_description: "Low Security",
class_title: "Low Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
},
# Null-sec system
@ns_system_geminate => %{
solar_system_id: @ns_system_geminate,
solar_system_name: "Geminate",
solar_system_name_lc: "geminate",
region_id: 10_000_029,
constellation_id: 20_000_400,
region_name: "Geminate",
constellation_name: "Geminate",
system_class: @ns,
security: "-0.5",
type_description: "Null Security",
class_title: "Null Sec",
is_shattered: false,
effect_name: nil,
effect_power: nil,
statics: [],
wandering: [],
triglavian_invasion_status: nil,
sun_type_id: 45041
}
}
Enum.each(test_systems, fn {solar_system_id, system_info} ->
Cachex.put(:system_static_info_cache, solar_system_id, system_info)
end)
:ok
end
describe "Scope filtering logic tests" do
# These tests verify the filtering logic without full integration
# The actual filtering is tested more comprehensively in map_scopes_test.exs
alias WandererApp.Map.Server.ConnectionsImpl
alias WandererApp.Map.Server.SystemsImpl
test "can_add_location correctly filters high-sec with [:wormholes, :null] scopes" do
# High-sec should NOT be allowed with [:wormholes, :null]
refute ConnectionsImpl.can_add_location([:wormholes, :null], @hs_system_halenan),
"High-sec should be filtered out with [:wormholes, :null] scopes"
refute ConnectionsImpl.can_add_location([:wormholes, :null], @hs_system_mili),
"High-sec should be filtered out with [:wormholes, :null] scopes"
end
test "can_add_location correctly filters low-sec with [:wormholes, :null] scopes" do
# Low-sec should NOT be allowed with [:wormholes, :null]
refute ConnectionsImpl.can_add_location([:wormholes, :null], @ls_system_halmah),
"Low-sec should be filtered out with [:wormholes, :null] scopes"
end
test "can_add_location correctly allows wormholes with [:wormholes, :null] scopes" do
# Wormholes should be allowed
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_j100001),
"Wormhole should be allowed with [:wormholes, :null] scopes"
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_j100002),
"Wormhole should be allowed with [:wormholes, :null] scopes"
end
test "can_add_location correctly allows null-sec with [:wormholes, :null] scopes" do
# Null-sec should be allowed
assert ConnectionsImpl.can_add_location([:wormholes, :null], @ns_system_geminate),
"Null-sec should be allowed with [:wormholes, :null] scopes"
end
test "maybe_add_system filters out high-sec when scopes is [:wormholes, :null]" do
# When scopes is [:wormholes, :null], high-sec systems should be filtered
location = %{solar_system_id: @hs_system_halenan}
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes, :null])
# Returns :ok because system was filtered out (not an error, just skipped)
assert result == :ok
end
test "maybe_add_system filters out low-sec when scopes is [:wormholes, :null]" do
location = %{solar_system_id: @ls_system_halmah}
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes, :null])
assert result == :ok
end
test "is_connection_valid allows WH to HS with [:wormholes, :null] (border behavior)" do
# The connection is valid for border behavior - but individual systems are filtered
assert ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@wh_system_j100001,
@hs_system_halenan
),
"WH to HS connection should be valid (border behavior)"
end
test "is_connection_valid rejects HS to LS with [:wormholes, :null] (no border)" do
# HS to LS should be rejected - neither system matches scopes and no wormhole involved
refute ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@hs_system_halenan,
@ls_system_halmah
),
"HS to LS connection should be rejected with [:wormholes, :null]"
end
test "is_connection_valid rejects HS to HS with [:wormholes, :null]" do
# HS to HS should be rejected
refute ConnectionsImpl.is_connection_valid(
[:wormholes, :null],
@hs_system_halenan,
@hs_system_mili
),
"HS to HS connection should be rejected with [:wormholes, :null]"
end
end
describe "get_effective_scopes behavior" do
alias WandererApp.Map.Server.CharactersImpl
test "get_effective_scopes returns scopes array when present" do
# Create a map struct with scopes array
map = %{scopes: [:wormholes, :null]}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:wormholes, :null]
end
test "get_effective_scopes converts legacy :all scope" do
map = %{scope: :all}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:wormholes, :hi, :low, :null, :pochven]
end
test "get_effective_scopes converts legacy :wormholes scope" do
map = %{scope: :wormholes}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:wormholes]
end
test "get_effective_scopes converts legacy :stargates scope" do
map = %{scope: :stargates}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:hi, :low, :null, :pochven]
end
test "get_effective_scopes converts legacy :none scope" do
map = %{scope: :none}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == []
end
test "get_effective_scopes defaults to [:wormholes] when no scope" do
map = %{}
scopes = CharactersImpl.get_effective_scopes(map)
assert scopes == [:wormholes]
end
end
end

View File

@@ -14,6 +14,10 @@ defmodule WandererAppWeb.MapSystemAPIControllerSuccessTest do
# Setup DDRT (R-tree) mock stubs for system positioning
setup_ddrt_mocks()
# Setup system static info cache with test systems
# This is required because CachedInfo reads from Cachex cache first
setup_system_static_info_cache()
user = insert(:user)
character = insert(:character, %{user_id: user.id})
map = insert(:map, %{owner_id: character.id})
@@ -36,7 +40,8 @@ defmodule WandererAppWeb.MapSystemAPIControllerSuccessTest do
# Ensure it's stopped first to prevent state leakage from previous tests
ensure_map_stopped(map.id)
# Seed static solar system data
# Seed static solar system data in database
# (Also populate cache above for CachedInfo lookups)
insert(:solar_system, %{
solar_system_id: 30_000_142,
solar_system_name: "Jita",
@@ -248,6 +253,9 @@ defmodule WandererAppWeb.MapSystemAPIControllerSuccessTest do
# Setup DDRT (R-tree) mock stubs for system positioning
setup_ddrt_mocks()
# Setup system static info cache with test systems
setup_system_static_info_cache()
user = insert(:user)
character = insert(:character, %{user_id: user.id})
map = insert(:map, %{owner_id: character.id})

Some files were not shown because too many files have changed in this diff Show More