Compare commits

...

96 Commits

Author SHA1 Message Date
CI
19c7fe59ee chore: release version v1.91.8 2026-01-06 14:07:08 +00:00
Dmitry Popov
682100c231 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-06 15:06:34 +01:00
Dmitry Popov
f9ac79cdcc fix(core): fixed rally point cancel logic 2026-01-06 15:06:31 +01:00
CI
f09f220645 chore: [skip ci] 2026-01-05 20:29:10 +00:00
CI
e585cdfd20 chore: release version v1.91.7 2026-01-05 20:29:10 +00:00
Dmitry Popov
3a3180f7b3 Merge branch 'main' of github.com:wanderer-industries/wanderer 2026-01-05 21:28:38 +01:00
Dmitry Popov
53abc580e5 chore: added promo on characters page 2026-01-05 21:28:35 +01:00
CI
8710d172a0 chore: [skip ci] 2026-01-04 23:49:15 +00:00
CI
301a380a4b chore: release version v1.91.6 2026-01-04 23:49:15 +00:00
Dmitry Popov
8c911f89e0 fix(core): fixed new connections got deleted after linked signature cleanup 2026-01-05 00:48:38 +01:00
CI
d7e09fc94e chore: [skip ci] 2025-12-30 10:49:35 +00:00
CI
3b7e191898 chore: release version v1.91.5 2025-12-30 10:49:35 +00:00
Dmitry Popov
f351fbaf20 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-30 11:49:02 +01:00
Dmitry Popov
016e793ba7 chore: Added 2026 roadmap blog post 2025-12-30 11:48:59 +01:00
CI
db483fd253 chore: [skip ci] 2025-12-30 09:27:37 +00:00
CI
911ba231cd chore: release version v1.91.4 2025-12-30 09:27:37 +00:00
Dmitry Popov
b3053f325d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-30 10:27:06 +01:00
Dmitry Popov
4ab47334fc fix(core): fixed connections create between k-space systems (considered as wh connection) 2025-12-30 10:27:03 +01:00
CI
e163f02526 chore: [skip ci] 2025-12-28 17:02:12 +00:00
CI
9e22dba8f1 chore: release version v1.91.3 2025-12-28 17:02:12 +00:00
Dmitry Popov
9631406def Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-28 18:01:43 +01:00
Dmitry Popov
f6ae448c3b chore: update event post 2025-12-28 18:01:39 +01:00
CI
46345ef596 chore: [skip ci] 2025-12-27 22:11:03 +00:00
CI
1625f16c8f chore: release version v1.91.2 2025-12-27 22:11:03 +00:00
Dmitry Popov
b4ef9ae983 fix(core): fixed map scopes updates & logic 2025-12-27 23:10:26 +01:00
CI
3b9c2dd996 chore: [skip ci] 2025-12-25 18:20:20 +00:00
CI
8a0f9a58d0 chore: release version v1.91.1 2025-12-25 18:20:20 +00:00
Dmitry Popov
5fe8caac0d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-25 19:19:47 +01:00
Dmitry Popov
f18f567727 chore: fix blog link styles 2025-12-25 19:19:44 +01:00
CI
91acc49980 chore: [skip ci] 2025-12-24 15:09:40 +00:00
CI
ae3873a225 chore: release version v1.91.0 2025-12-24 15:09:40 +00:00
Dmitry Popov
b351c6cc26 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-24 16:09:06 +01:00
Dmitry Popov
698244d945 feat(admin): added maps administration view with basic info, search, restore/delete, acls view and edit options 2025-12-24 16:09:03 +01:00
CI
2c7dd9dc5b chore: [skip ci] 2025-12-19 12:33:26 +00:00
CI
36934cce0b chore: release version v1.90.13 2025-12-19 12:33:26 +00:00
Dmitry Popov
b7da7e4ecb Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-19 13:32:46 +01:00
Dmitry Popov
6471ea5590 fix(core): fixed welcome page 2025-12-19 13:32:44 +01:00
CI
b46bcac642 chore: [skip ci] 2025-12-19 09:38:36 +00:00
CI
52d90361e9 chore: release version v1.90.12 2025-12-19 09:38:36 +00:00
Dmitry Popov
1c902d3319 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-19 10:38:02 +01:00
Dmitry Popov
8f671a359b fix(core): fixed permissions update after character corp updates 2025-12-19 10:37:59 +01:00
CI
840c416684 chore: [skip ci] 2025-12-18 21:47:59 +00:00
CI
56e29ad30a chore: release version v1.90.11 2025-12-18 21:47:59 +00:00
Dmitry Popov
cd8f8b5801 chore: added promo codes support for map subs 2025-12-18 22:19:50 +01:00
Dmitry Popov
70e013fa3d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-18 22:19:38 +01:00
Dmitry Popov
d6bfaf8008 chore: added promo codes support for map subs 2025-12-18 22:19:26 +01:00
CI
95944199a0 chore: [skip ci] 2025-12-18 18:05:48 +00:00
CI
3bd5db8cf3 chore: release version v1.90.10 2025-12-18 18:05:48 +00:00
Dmitry Popov
a245330ada Merge branch 'advent-challenge' 2025-12-18 19:05:10 +01:00
Dmitry Popov
1226b6abf3 chore: added advent challenge 2025-12-18 19:04:43 +01:00
Dmitry Popov
7a1f5c0966 chore: [skip ci] 2025-12-17 19:32:37 +01:00
CI
e5afa1d5bc chore: [skip ci] 2025-12-15 11:46:40 +00:00
CI
1473fe8646 chore: release version v1.90.9 2025-12-15 11:46:40 +00:00
Dmitry Popov
7039ced11e fix(core): reduce chracters untrack grace period to 15 mins (after change/close/disconnect from map)
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-15 12:46:02 +01:00
CI
42b5bb337f chore: [skip ci] 2025-12-15 11:35:24 +00:00
CI
1dbb24f6ec chore: release version v1.90.8 2025-12-15 11:35:24 +00:00
Dmitry Popov
c242f510e0 fix(core): skip systems or connections cleanup for not started maps 2025-12-15 12:34:55 +01:00
CI
c59d51636e chore: [skip ci] 2025-12-15 00:36:18 +00:00
CI
c5a8aa1b4d chore: release version v1.90.7 2025-12-15 00:36:18 +00:00
Dmitry Popov
cba050a9e7 fix(core): fixed scopes 2025-12-15 01:35:41 +01:00
CI
59fcbef3b1 chore: [skip ci] 2025-12-12 18:49:02 +00:00
CI
2f1eb6eeaa chore: release version v1.90.6 2025-12-12 18:49:02 +00:00
Dmitry Popov
71ae326cf7 fix(core): fixed map scopes 2025-12-12 19:48:26 +01:00
CI
07829caf0f chore: [skip ci] 2025-12-12 18:36:03 +00:00
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
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
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
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
a237d6513d Merge branch 'main' into develop 2025-12-02 13:37:13 +01: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
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
153 changed files with 5776 additions and 632 deletions

View File

@@ -16,3 +16,8 @@ export WANDERER_SSE_ENABLED="true"
export WANDERER_WEBHOOKS_ENABLED="true"
export WANDERER_SSE_MAX_CONNECTIONS="1000"
export WANDERER_WEBHOOK_TIMEOUT_MS="15000"
# Promo codes for map subscriptions (optional)
# Format: CODE:DISCOUNT_PERCENT,CODE2:DISCOUNT_PERCENT2
# Codes are case-insensitive, discounts stack with period discounts
# export WANDERER_PROMO_CODES="PROMO2025:10,NEWUSER:20"

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ repomix*
/priv/static/images/
/priv/static/*.js
/priv/static/*.css
/priv/static/*-*.png
/priv/static/*-*.webp
/priv/static/*-*.webmanifest
# Dialyzer PLT files
/priv/plts/

View File

@@ -2,6 +2,197 @@
<!-- changelog -->
## [v1.91.8](https://github.com/wanderer-industries/wanderer/compare/v1.91.7...v1.91.8) (2026-01-06)
### Bug Fixes:
* core: fixed rally point cancel logic
## [v1.91.7](https://github.com/wanderer-industries/wanderer/compare/v1.91.6...v1.91.7) (2026-01-05)
## [v1.91.6](https://github.com/wanderer-industries/wanderer/compare/v1.91.5...v1.91.6) (2026-01-04)
### Bug Fixes:
* core: fixed new connections got deleted after linked signature cleanup
## [v1.91.5](https://github.com/wanderer-industries/wanderer/compare/v1.91.4...v1.91.5) (2025-12-30)
## [v1.91.4](https://github.com/wanderer-industries/wanderer/compare/v1.91.3...v1.91.4) (2025-12-30)
### Bug Fixes:
* core: fixed connections create between k-space systems (considered as wh connection)
## [v1.91.3](https://github.com/wanderer-industries/wanderer/compare/v1.91.2...v1.91.3) (2025-12-28)
## [v1.91.2](https://github.com/wanderer-industries/wanderer/compare/v1.91.1...v1.91.2) (2025-12-27)
### Bug Fixes:
* core: fixed map scopes updates & logic
## [v1.91.1](https://github.com/wanderer-industries/wanderer/compare/v1.91.0...v1.91.1) (2025-12-25)
## [v1.91.0](https://github.com/wanderer-industries/wanderer/compare/v1.90.13...v1.91.0) (2025-12-24)
### Features:
* admin: added maps administration view with basic info, search, restore/delete, acls view and edit options
## [v1.90.13](https://github.com/wanderer-industries/wanderer/compare/v1.90.12...v1.90.13) (2025-12-19)
### Bug Fixes:
* core: fixed welcome page
## [v1.90.12](https://github.com/wanderer-industries/wanderer/compare/v1.90.11...v1.90.12) (2025-12-19)
### Bug Fixes:
* core: fixed permissions update after character corp updates
## [v1.90.11](https://github.com/wanderer-industries/wanderer/compare/v1.90.10...v1.90.11) (2025-12-18)
## [v1.90.10](https://github.com/wanderer-industries/wanderer/compare/v1.90.9...v1.90.10) (2025-12-18)
## [v1.90.9](https://github.com/wanderer-industries/wanderer/compare/v1.90.8...v1.90.9) (2025-12-15)
### Bug Fixes:
* core: reduce chracters untrack grace period to 15 mins (after change/close/disconnect from map)
## [v1.90.8](https://github.com/wanderer-industries/wanderer/compare/v1.90.7...v1.90.8) (2025-12-15)
### Bug Fixes:
* core: skip systems or connections cleanup for not started maps
## [v1.90.7](https://github.com/wanderer-industries/wanderer/compare/v1.90.6...v1.90.7) (2025-12-15)
### Bug Fixes:
* core: fixed scopes
## [v1.90.6](https://github.com/wanderer-industries/wanderer/compare/v1.90.5...v1.90.6) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes
## [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)

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

@@ -1001,3 +1001,27 @@ body > div:first-of-type {
.verticalTabsContainer .p-tabview-panel {
flex-grow: 1;
}
/* Blog post CTA links - only in main post content */
.post-content a {
display: inline-block;
background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
color: white !important;
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
text-decoration: none !important;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.3);
}
.post-content a:hover {
background: linear-gradient(135deg, #db2777 0%, #7c3aed 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(236, 72, 153, 0.4);
}
.post-content a:active {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(236, 72, 153, 0.3);
}

View File

@@ -72,7 +72,7 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
const {
storedSettings: { interfaceSettings },
data: { systemSignatures: mapSystemSignatures },
data: { systemSignatures: mapSystemSignatures, pings },
} = useMapRootState();
const systemStaticInfo = useMemo(() => {
@@ -108,7 +108,6 @@ export const useSolarSystemNode = (props: NodeProps<MapSolarSystemType>): SolarS
visibleNodes,
showKSpaceBG,
isThickConnections,
pings,
systemHighlighted,
},
outCommand,

View File

@@ -10,3 +10,4 @@ export * from './useCommandComments';
export * from './useGetCacheCharacter';
export * from './useCommandsActivity';
export * from './useCommandPings';
export * from './useCommandPingBlocked';

View File

@@ -0,0 +1,21 @@
import { useToast } from '@/hooks/Mapper/ToastProvider';
import { CommandPingBlocked } from '@/hooks/Mapper/types';
import { useCallback } from 'react';
export const useCommandPingBlocked = () => {
const { show } = useToast();
const pingBlocked = useCallback(
({ message }: CommandPingBlocked) => {
show({
severity: 'warn',
summary: 'Cannot create ping',
detail: message,
life: 5000,
});
},
[show],
);
return { pingBlocked };
};

View File

@@ -14,8 +14,8 @@ export const useCommandPings = () => {
ref.current.update({ pings });
}, []);
const pingCancelled = useCallback(({ type, id }: CommandPingCancelled) => {
const newPings = ref.current.pings.filter(x => x.id !== id && x.type !== type);
const pingCancelled = useCallback(({ id }: CommandPingCancelled) => {
const newPings = ref.current.pings.filter(x => x.id !== id);
ref.current.update({ pings: newPings });
}, []);

View File

@@ -12,6 +12,7 @@ import {
CommandLinkSignatureToSystem,
CommandMapUpdated,
CommandPingAdded,
CommandPingBlocked,
CommandPingCancelled,
CommandPresentCharacters,
CommandRemoveConnections,
@@ -29,6 +30,7 @@ import { ForwardedRef, useImperativeHandle } from 'react';
import {
useCommandComments,
useCommandPingBlocked,
useCommandPings,
useCommandsCharacters,
useCommandsConnections,
@@ -61,6 +63,7 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const mapUserRoutes = useUserRoutes();
const { addComment, removeComment } = useCommandComments();
const { pingAdded, pingCancelled } = useCommandPings();
const { pingBlocked } = useCommandPingBlocked();
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
useImperativeHandle(ref, () => {
@@ -172,6 +175,9 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
case Commands.pingCancelled:
pingCancelled(data as CommandPingCancelled);
break;
case Commands.pingBlocked:
pingBlocked(data as CommandPingBlocked);
break;
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;

View File

@@ -41,6 +41,7 @@ export enum Commands {
refreshTrackingData = 'refresh_tracking_data',
pingAdded = 'ping_added',
pingCancelled = 'ping_cancelled',
pingBlocked = 'ping_blocked',
}
export type Command =
@@ -77,7 +78,8 @@ export type Command =
| Commands.showTracking
| Commands.refreshTrackingData
| Commands.pingAdded
| Commands.pingCancelled;
| Commands.pingCancelled
| Commands.pingBlocked;
export type CommandInit = {
systems: SolarSystemRawType[];
@@ -161,6 +163,10 @@ export type CommandUpdateTracking = {
};
export type CommandPingAdded = PingData[];
export type CommandPingCancelled = Pick<PingData, 'type' | 'id'>;
export type CommandPingBlocked = {
reason: string;
message: string;
};
export interface UserSettings {
primaryCharacterId?: string;
@@ -212,6 +218,7 @@ export interface CommandData {
[Commands.refreshTrackingData]: CommandRefreshTrackingData;
[Commands.pingAdded]: CommandPingAdded;
[Commands.pingCancelled]: CommandPingCancelled;
[Commands.pingBlocked]: CommandPingBlocked;
}
export interface MapHandlers {

View File

@@ -57,7 +57,7 @@ export default {
};
refreshZone.addEventListener('click', handleUpdate);
refreshZone.addEventListener('mouseover', handleUpdate);
// refreshZone.addEventListener('mouseover', handleUpdate);
this.updated();
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -92,6 +92,31 @@ map_subscription_extra_hubs_10_price =
config_dir
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_EXTRA_HUBS_10_PRICE", 10_000_000)
# Parse promo codes from environment variable
# Format: "CODE1:10,CODE2:20" where numbers are discount percentages
promo_codes =
config_dir
|> get_var_from_path_or_env("WANDERER_PROMO_CODES", "")
|> case do
"" ->
%{}
codes_string ->
codes_string
|> String.split(",")
|> Enum.map(fn entry ->
case String.split(String.trim(entry), ":") do
[code, discount] ->
{String.upcase(String.trim(code)), String.to_integer(String.trim(discount))}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
end
map_connection_auto_expire_hours =
config_dir
|> get_int_from_path_or_env("WANDERER_MAP_CONNECTION_AUTO_EXPIRE_HOURS", 24)
@@ -176,7 +201,8 @@ config :wanderer_app,
}
],
extra_characters_50: map_subscription_extra_characters_50_price,
extra_hubs_10: map_subscription_extra_hubs_10_price
extra_hubs_10: map_subscription_extra_hubs_10_price,
promo_codes: promo_codes
},
# Finch pool configuration - separate pools for different services
# ESI Character Tracking pool - high capacity for bulk character operations
@@ -264,7 +290,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
@@ -65,6 +67,8 @@ defmodule WandererApp.Api.Map do
)
define(:duplicate, action: :duplicate)
define(:admin_all, action: :admin_all)
define(:restore, action: :restore)
end
calculations do
@@ -105,12 +109,19 @@ defmodule WandererApp.Api.Map do
prepare WandererApp.Api.Preparations.FilterMapsByRoles
end
read :admin_all do
# Admin-only action that bypasses FilterMapsByRoles
# Returns ALL maps including soft-deleted ones with owner and ACLs loaded
prepare build(load: [:owner, :acls])
end
create :new do
accept [
:name,
:slug,
:description,
:scope,
:scopes,
:only_tracked_characters,
:owner_id,
:sse_enabled
@@ -135,6 +146,7 @@ defmodule WandererApp.Api.Map do
:slug,
:description,
:scope,
:scopes,
:only_tracked_characters,
:owner_id,
:sse_enabled
@@ -190,6 +202,14 @@ defmodule WandererApp.Api.Map do
change(set_attribute(:deleted, true))
end
update :restore do
# Admin-only action to restore a soft-deleted map
accept([])
require_atomic? false
change(set_attribute(:deleted, false))
end
update :update_api_key do
accept [:public_api_key]
require_atomic? false
@@ -209,7 +229,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 +245,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 +384,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

@@ -80,6 +80,10 @@ defmodule WandererApp.Api.MapPing do
filter(expr(inserted_at <= ^arg(:inserted_before)))
end
# Admin action for cleanup - no actor filtering
read :all_pings do
end
end
attributes do

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
_ ->
@@ -734,6 +731,14 @@ defmodule WandererApp.Character.Tracker do
{:character_alliance, {character_id, character_update}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{alliance_id: nil})
end
@@ -772,6 +777,14 @@ defmodule WandererApp.Character.Tracker do
{:character_alliance, {character_id, character_update}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{alliance_id: alliance_id})
@@ -799,7 +812,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)
@@ -826,6 +839,14 @@ defmodule WandererApp.Character.Tracker do
}}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{corporation_id: corporation_id})
@@ -1002,7 +1023,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

@@ -69,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
@@ -189,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

@@ -42,6 +42,35 @@ defmodule WandererApp.Env do
def corp_eve_id(), do: get_key(:corp_id, -1)
def subscription_settings(), do: get_key(:subscription_settings)
@doc """
Returns the promo code configuration map.
Keys are uppercase code strings, values are discount percentages.
"""
def promo_codes() do
case subscription_settings() do
%{promo_codes: codes} when is_map(codes) -> codes
_ -> %{}
end
end
@doc """
Validates a promo code and returns the discount percentage.
Returns {:ok, discount_percent} if valid, {:error, :invalid_code} otherwise.
Codes are case-insensitive.
"""
def validate_promo_code(nil), do: {:error, :invalid_code}
def validate_promo_code(""), do: {:error, :invalid_code}
def validate_promo_code(code) when is_binary(code) do
normalized = String.upcase(String.trim(code))
case Map.get(promo_codes(), normalized) do
nil -> {:error, :invalid_code}
discount when is_integer(discount) and discount > 0 and discount <= 100 -> {:ok, discount}
_ -> {:error, :invalid_code}
end
end
@decorate cacheable(
cache: WandererApp.Cache,
key: "restrict_maps_creation"

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

@@ -7,7 +7,8 @@ defmodule WandererApp.EveDataService do
alias WandererApp.Utils.JSONUtil
@eve_db_dump_url "https://www.fuzzwork.co.uk/dump/latest"
# @eve_db_dump_url "https://www.fuzzwork.co.uk/dump/latest"
@eve_db_dump_url "https://wanderer-industries.github.io/wanderer-assets/sde-files"
@dump_file_names [
"invGroups.csv",
@@ -393,9 +394,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 +411,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 +498,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

@@ -12,6 +12,7 @@ defmodule WandererApp.Map do
defstruct map_id: nil,
name: nil,
scope: :none,
scopes: nil,
owner_id: nil,
characters: [],
systems: Map.new(),
@@ -22,11 +23,18 @@ defmodule WandererApp.Map do
characters_limit: nil,
hubs_limit: nil
def new(%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs}) do
def new(
%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs} =
input
) do
# Extract the new scopes array field if present (nil if not set)
scopes = Map.get(input, :scopes)
map =
struct!(__MODULE__,
map_id: map_id,
scope: scope,
scopes: scopes,
owner_id: owner_id,
name: name,
acls: acls,
@@ -177,7 +185,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 +323,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 +334,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

@@ -16,7 +16,7 @@ defmodule WandererApp.Map.Manager do
@maps_queue :maps_queue
@check_maps_queue_interval :timer.seconds(1)
@pings_cleanup_interval :timer.minutes(10)
@pings_cleanup_interval :timer.minutes(1)
@pings_expire_minutes 60
# Test-aware async task runner
@@ -99,6 +99,7 @@ defmodule WandererApp.Map.Manager do
def handle_info(:cleanup_pings, state) do
try do
cleanup_expired_pings()
cleanup_orphaned_pings()
{:noreply, state}
rescue
e ->
@@ -141,6 +142,51 @@ defmodule WandererApp.Map.Manager do
end
end
defp cleanup_orphaned_pings() do
case WandererApp.MapPingsRepo.get_orphaned_pings() do
{:ok, []} ->
:ok
{:ok, orphaned_pings} ->
Logger.info(
"[cleanup_orphaned_pings] Found #{length(orphaned_pings)} orphaned pings, cleaning up..."
)
Enum.each(orphaned_pings, fn %{id: ping_id, map_id: map_id, type: type, system: system} = ping ->
reason =
cond do
is_nil(ping.system) -> "system deleted"
is_nil(ping.character) -> "character deleted"
is_nil(ping.map) -> "map deleted"
not is_nil(system) and system.visible == false -> "system hidden (visible=false)"
true -> "unknown"
end
Logger.warning(
"[cleanup_orphaned_pings] Destroying orphaned ping #{ping_id} (map_id: #{map_id}, reason: #{reason})"
)
# Broadcast cancellation if map_id is still valid
if map_id do
Server.Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
end
Ash.destroy!(ping)
end)
Logger.info("[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings")
:ok
{:error, error} ->
Logger.error("Failed to fetch orphaned pings: #{inspect(error)}")
{:error, error}
end
end
defp start_maps() do
chunks =
@maps_queue

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

@@ -72,28 +72,36 @@ defmodule WandererApp.Map.SubscriptionManager do
:ok
end
def estimate_price(params, renew?, promo_code \\ nil)
def estimate_price(
%{
"period" => period,
"characters_limit" => characters_limit,
"hubs_limit" => hubs_limit
},
renew?
} = params,
renew?,
promo_code
)
when is_binary(characters_limit),
do:
estimate_price(
%{
period: period |> String.to_integer(),
characters_limit: characters_limit |> String.to_integer(),
hubs_limit: hubs_limit |> String.to_integer()
},
renew?
)
when is_binary(characters_limit) do
# Extract promo_code from params if passed there (from form)
promo_code = promo_code || Map.get(params, "promo_code")
estimate_price(
%{
period: period |> String.to_integer(),
characters_limit: characters_limit |> String.to_integer(),
hubs_limit: hubs_limit |> String.to_integer()
},
renew?,
promo_code
)
end
def estimate_price(
%{characters_limit: characters_limit, hubs_limit: hubs_limit} = params,
renew?
renew?,
promo_code
) do
%{
plans: plans,
@@ -136,7 +144,7 @@ defmodule WandererApp.Map.SubscriptionManager do
total_price = estimated_price * period
{:ok, discount} =
{:ok, period_discount} =
calc_discount(
period,
total_price,
@@ -144,13 +152,27 @@ defmodule WandererApp.Map.SubscriptionManager do
renew?
)
{:ok, total_price, discount}
# Calculate promo discount on price after period discount
price_after_period_discount = total_price - period_discount
{:ok, promo_discount, promo_valid?} =
calc_promo_discount(promo_code, price_after_period_discount)
total_discount = period_discount + promo_discount
{:ok, total_price, total_discount, promo_valid?}
end
def calc_additional_price(params, selected_subscription, promo_code \\ nil)
def calc_additional_price(
%{"characters_limit" => characters_limit, "hubs_limit" => hubs_limit},
selected_subscription
%{"characters_limit" => characters_limit, "hubs_limit" => hubs_limit} = params,
selected_subscription,
promo_code
) do
# Extract promo_code from params if passed there (from form)
promo_code = promo_code || Map.get(params, "promo_code")
%{
plans: plans,
extra_characters_50: extra_characters_50,
@@ -189,7 +211,7 @@ defmodule WandererApp.Map.SubscriptionManager do
total_price = additional_price * period
{:ok, discount} =
{:ok, period_discount} =
calc_discount(
period,
total_price,
@@ -197,7 +219,15 @@ defmodule WandererApp.Map.SubscriptionManager do
false
)
{:ok, total_price, discount}
# Calculate promo discount on price after period discount
price_after_period_discount = total_price - period_discount
{:ok, promo_discount, promo_valid?} =
calc_promo_discount(promo_code, price_after_period_discount)
total_discount = period_discount + promo_discount
{:ok, total_price, total_discount, promo_valid?}
end
defp get_active_months(subscription) do
@@ -255,6 +285,22 @@ defmodule WandererApp.Map.SubscriptionManager do
when period >= 3,
do: {:ok, round(total_price * month_3_discount)}
# Calculates the promo code discount amount.
# Returns {:ok, discount_amount, is_valid?}
defp calc_promo_discount(nil, _price), do: {:ok, 0, false}
defp calc_promo_discount("", _price), do: {:ok, 0, false}
defp calc_promo_discount(promo_code, price) when is_binary(promo_code) do
case WandererApp.Env.validate_promo_code(promo_code) do
{:ok, discount_percent} ->
discount_amount = round(price * discount_percent / 100)
{:ok, discount_amount, true}
{:error, :invalid_code} ->
{:ok, 0, false}
end
end
def get_balance(map) do
map
|> WandererApp.MapRepo.load_relationships([
@@ -302,7 +348,8 @@ defmodule WandererApp.Map.SubscriptionManager do
defp renew_subscription(%{auto_renew?: true, map: map} = subscription)
when is_map(subscription) do
with {:ok, estimated_price, discount} <- estimate_price(subscription, true),
# No promo code for auto-renewals, ignore the promo_valid? return value
with {:ok, estimated_price, discount, _promo_valid?} <- estimate_price(subscription, true),
{:ok, map_balance} <- get_balance(map) do
case map_balance >= estimated_price do
true ->

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

@@ -56,7 +56,7 @@ defmodule WandererApp.Map.Server.AclsImpl do
end
)
map_update = %{acls: map.acls, scope: map.scope}
map_update = %{acls: map.acls, scope: map.scope, scopes: map.scopes}
WandererApp.Map.update_map(map_id, map_update)
WandererApp.Cache.delete("map_characters-#{map_id}")

View File

@@ -569,6 +569,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
# Broadcast permission update to trigger LiveView refresh
broadcast_permission_update(character_id)
:has_update
{:character_corporation, _info} ->
@@ -580,6 +583,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
)
# Broadcast permission update to trigger LiveView refresh
broadcast_permission_update(character_id)
:has_update
_ ->
@@ -814,21 +820,33 @@ 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
ConnectionsImpl.is_connection_valid(
scope,
old_location.solar_system_id,
location.solar_system_id
scopes = get_effective_scopes(map)
is_valid =
ConnectionsImpl.is_connection_valid(
scopes,
old_location.solar_system_id,
location.solar_system_id
)
Logger.debug(
"[CharacterTracking] update_location: map=#{map_id}, " <>
"from=#{old_location.solar_system_id}, to=#{location.solar_system_id}, " <>
"scopes=#{inspect(scopes)}, map.scopes=#{inspect(map[:scopes])}, " <>
"map.scope=#{inspect(map[:scope])}, is_valid=#{is_valid}"
)
|> case do
case is_valid 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 +856,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 +897,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,
@@ -923,4 +959,21 @@ defmodule WandererApp.Map.Server.CharactersImpl do
track: true
})
end
# Broadcasts permission update to trigger LiveView refresh for the character's user.
# This is called when a character's corporation or alliance changes, ensuring
# users are kicked off maps they no longer have access to.
defp broadcast_permission_update(character_id) do
case WandererApp.Character.get_character(character_id) do
{:ok, %{eve_id: eve_id}} when not is_nil(eve_id) ->
Phoenix.PubSub.broadcast(
WandererApp.PubSub,
"character:#{eve_id}",
:update_permissions
)
_ ->
:ok
end
end
end

View File

@@ -5,6 +5,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
alias WandererApp.Map.Server.Impl
alias WandererApp.Map.Server.SignaturesImpl
alias WandererApp.Map.Server.SystemsImpl
# @ccp1 -1
@c1 1
@@ -57,6 +58,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 +107,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()
@@ -290,6 +297,30 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
do: update_connection(map_id, :update_custom_info, [:custom_info], connection_update)
def cleanup_connections(map_id) do
# Defensive check: Skip cleanup if cache appears invalid
# This prevents incorrectly deleting connections when cache is empty due to
# race conditions during map restart or cache corruption
case WandererApp.Map.get_map(map_id) do
{:error, :not_found} ->
Logger.warning(
"[cleanup_connections] Skipping map #{map_id} - cache miss detected, " <>
"map data not found in cache"
)
:telemetry.execute(
[:wanderer_app, :map, :cleanup_connections, :cache_miss],
%{system_time: System.system_time()},
%{map_id: map_id}
)
:ok
{:ok, _map} ->
do_cleanup_connections(map_id)
end
end
defp do_cleanup_connections(map_id) do
connection_auto_expire_hours = get_connection_auto_expire_hours()
connection_auto_eol_hours = get_connection_auto_eol_hours()
connection_eol_expire_timeout_hours = get_eol_expire_timeout_mins() / 60
@@ -343,6 +374,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 +455,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 +696,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 +758,81 @@ 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
cond do
# Case 1: Wormhole border behavior - at least one system is a wormhole
# and :wormholes is enabled, allow the connection (adds border k-space systems)
wormholes_enabled and (from_is_wormhole or to_is_wormhole) ->
# 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)
# Case 2: K-space to K-space with :wormholes enabled - check if it's a wormhole connection
# If neither system is a wormhole AND there's no stargate between them, it's a wormhole connection
wormholes_enabled and not from_is_wormhole and not to_is_wormhole ->
# Check if there's a known stargate connection
case find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
{:ok, known_jumps} when known_jumps == [] ->
# No stargate exists - this is a wormhole connection through k-space
true
{:ok, _known_jumps} ->
# Stargate exists - this is NOT a wormhole, check normal scope matching
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
_ ->
# Error fetching jumps - fall back to scope matching
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
end
# Case 3: Non-wormhole movement without :wormholes scope
# Both systems must match the configured scopes
true ->
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 +846,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 +854,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")
@@ -746,6 +888,44 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
end
end
@doc """
Check if a connection between two k-space systems is a wormhole connection.
Returns true if:
1. Both systems are k-space (not wormhole space)
2. There is no known stargate between them
This is used to detect wormhole connections through k-space, like when
a player jumps from low-sec to low-sec through a wormhole.
"""
def is_kspace_wormhole_connection?(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_kspace_wormhole_connection?(from_solar_system_id, to_solar_system_id)
when from_solar_system_id == to_solar_system_id,
do: false
def is_kspace_wormhole_connection?(from_solar_system_id, to_solar_system_id) do
with {:ok, from_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_info} <- get_system_static_info(to_solar_system_id) do
from_is_wormhole = from_info.system_class in @wh_space
to_is_wormhole = to_info.system_class in @wh_space
# Both must be k-space (not wormhole space)
if not from_is_wormhole and not to_is_wormhole do
# Check if there's a known stargate
case find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
{:ok, []} -> true # No stargate = wormhole connection
_ -> false # Stargate exists or error
end
else
false
end
else
_ -> false
end
end
defp get_system_static_info(solar_system_id) do
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system_static_info} when not is_nil(system_static_info) ->
@@ -779,6 +959,13 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
WandererApp.Cache.delete("map_#{map_id}:conn_#{connection.id}:start_time")
# Clear linked_sig_eve_id on target system when connection is deleted
# This ensures old signatures become orphaned and won't affect future connections
SystemsImpl.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: location.solar_system_id,
linked_sig_eve_id: nil
})
_error ->
:ok
end
@@ -901,9 +1088,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,
@@ -72,11 +72,15 @@ defmodule WandererApp.Map.Server.PingsImpl do
type: type
} = _ping_info
) do
Logger.debug("cancel_ping called: map_id=#{map_id}, ping_id=#{ping_id}, type=#{type}")
case WandererApp.MapPingsRepo.get_by_id(ping_id) do
{:ok,
%{system: %{id: system_id, name: system_name, solar_system_id: solar_system_id}} = ping} ->
with {:ok, character} <- WandererApp.Character.get_character(character_id),
:ok <- WandererApp.MapPingsRepo.destroy(ping) do
Logger.debug("Ping #{ping_id} destroyed successfully")
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: solar_system_id,
@@ -107,6 +111,22 @@ defmodule WandererApp.Map.Server.PingsImpl do
Logger.error("Failed to destroy ping: #{inspect(error, pretty: true)}")
end
# Handle case where ping exists but system was deleted (nil)
{:ok, %{system: nil} = ping} ->
Logger.warning("Ping #{ping_id} has no associated system, destroying orphaned ping")
case WandererApp.MapPingsRepo.destroy(ping) do
:ok ->
Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
error ->
Logger.error("Failed to destroy orphaned ping: #{inspect(error, pretty: true)}")
end
{:error, %Ash.Error.Query.NotFound{}} ->
# Ping already deleted (possibly by cascade deletion from map/system/character removal,
# auto-expiry, or concurrent cancellation). This is not an error - the desired state
@@ -117,8 +137,18 @@ defmodule WandererApp.Map.Server.PingsImpl do
:ok
error ->
Logger.error("Failed to fetch ping for cancellation: #{inspect(error, pretty: true)}")
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
# Same as above, but Ash wraps NotFound inside Invalid in some cases
Logger.debug(
"Ping #{ping_id} not found during cancellation - already deleted, skipping broadcast"
)
:ok
other ->
Logger.error(
"Failed to cancel ping #{ping_id}: unexpected result from get_by_id: #{inspect(other, pretty: true)}"
)
end
end
end

View File

@@ -170,16 +170,20 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
end
defp remove_signature(map_id, sig, system, delete_conn?) do
# optionally remove the linked connection
if delete_conn? && sig.linked_system_id do
# Check if this signature is the active one for the target system
# This prevents deleting connections when old/orphan signatures are removed
is_active = sig.linked_system_id && is_active_signature_for_target?(map_id, sig)
# Only delete connection if this signature is the active one
if delete_conn? && is_active do
ConnectionsImpl.delete_connection(map_id, %{
solar_system_source_id: system.solar_system_id,
solar_system_target_id: sig.linked_system_id
})
end
# clear any linked_sig_eve_id on the target system
if sig.linked_system_id do
# Only clear linked_sig_eve_id if this signature is the active one
if is_active do
SystemsImpl.update_system_linked_sig_eve_id(map_id, %{
solar_system_id: sig.linked_system_id,
linked_sig_eve_id: nil
@@ -190,6 +194,16 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|> MapSystemSignature.destroy!()
end
defp is_active_signature_for_target?(map_id, sig) do
case MapSystem.read_by_map_and_solar_system(%{
map_id: map_id,
solar_system_id: sig.linked_system_id
}) do
{:ok, target_system} -> target_system.linked_sig_eve_id == sig.eve_id
_ -> false
end
end
def apply_update_signature(
map_id,
%MapSystemSignature{} = existing,
@@ -256,6 +270,37 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
defp maybe_update_connection_mass_status(_map_id, _old_sig, _updated_sig), do: :ok
@doc """
Wrapper for updating a signature's linked_system_id with logging.
Logs all unlink operations (when linked_system_id is set to nil) with context
to help diagnose unexpected unlinking issues.
"""
def update_signature_linked_system(signature, %{linked_system_id: nil} = params) do
# Log all unlink operations with context for debugging
Logger.warning(
"[Signature Unlink] eve_id=#{signature.eve_id} " <>
"system_id=#{signature.system_id} " <>
"old_linked_system_id=#{signature.linked_system_id} " <>
"stacktrace=#{format_stacktrace()}"
)
MapSystemSignature.update_linked_system(signature, params)
end
def update_signature_linked_system(signature, params) do
MapSystemSignature.update_linked_system(signature, params)
end
defp format_stacktrace do
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
stacktrace
|> Enum.take(10)
|> Enum.map_join(" <- ", fn {mod, fun, arity, _} ->
"#{inspect(mod)}.#{fun}/#{arity}"
end)
end
defp track_activity(event, map_id, solar_system_id, user_id, character_id, signatures) do
ActivityTracker.track_map_event(event, %{
map_id: map_id,

View File

@@ -4,6 +4,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
require Logger
alias WandererApp.Map.Server.Impl
alias WandererApp.Map.Server.SignaturesImpl
@ddrt Application.compile_env(:wanderer_app, :ddrt)
@system_auto_expire_minutes 15
@@ -129,8 +130,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)
@@ -146,6 +147,30 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
def cleanup_systems(map_id) do
# Defensive check: Skip cleanup if cache appears invalid
# This prevents incorrectly deleting systems when cache is empty due to
# race conditions during map restart or cache corruption
case WandererApp.Map.get_map(map_id) do
{:error, :not_found} ->
Logger.warning(
"[cleanup_systems] Skipping map #{map_id} - cache miss detected, " <>
"map data not found in cache"
)
:telemetry.execute(
[:wanderer_app, :map, :cleanup_systems, :cache_miss],
%{system_time: System.system_time()},
%{map_id: map_id}
)
:ok
{:ok, _map} ->
do_cleanup_systems(map_id)
end
end
defp do_cleanup_systems(map_id) do
expired_systems =
map_id
|> WandererApp.Map.list_systems!()
@@ -309,7 +334,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 +408,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 +428,77 @@ 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
# Use the wrapper to log unlink operations
case SignaturesImpl.update_signature_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 +523,62 @@ 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) ->
# First check: does the location directly match scopes?
if ConnectionsImpl.can_add_location(scopes, location.solar_system_id) do
true
else
# Second check: wormhole border behavior
# If :wormholes scope is enabled AND old_location is a wormhole,
# allow this system to be added as a border system (so you can see
# where your wormhole exits to)
wormhole_border_from_wh_space =
:wormholes in scopes and
not is_nil(old_location) and
ConnectionsImpl.can_add_location([:wormholes], old_location.solar_system_id)
# Third check: k-space wormhole connection
# If :wormholes scope is enabled AND there's no stargate between the systems,
# this is a wormhole connection through k-space - add both systems
kspace_wormhole_connection =
:wormholes in scopes and
not is_nil(old_location) and
not is_nil(old_location.solar_system_id) and
ConnectionsImpl.is_kspace_wormhole_connection?(
old_location.solar_system_id,
location.solar_system_id
)
wormhole_border_from_wh_space or kspace_wormhole_connection
end
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 +657,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 +786,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 +810,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 +877,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 +913,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 +1004,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 +1119,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

@@ -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

@@ -29,6 +29,34 @@ defmodule WandererApp.MapPingsRepo do
def get_by_inserted_before(inserted_before_date),
do: WandererApp.Api.MapPing.by_inserted_before(inserted_before_date)
@doc """
Returns all pings that have orphaned relationships (nil system, character, or map)
or where the system has been soft-deleted (visible = false).
These pings should be cleaned up as they can no longer be properly displayed or cancelled.
"""
def get_orphaned_pings() do
# Use :all_pings action which has no actor filtering (unlike primary :read)
case WandererApp.Api.MapPing |> Ash.Query.for_read(:all_pings) |> Ash.read() do
{:ok, pings} ->
# Load relationships and filter for orphaned ones
orphaned =
pings
|> Enum.map(fn ping ->
{:ok, loaded} = ping |> Ash.load([:system, :character, :map], authorize?: false)
loaded
end)
|> Enum.filter(fn ping ->
is_nil(ping.system) or is_nil(ping.character) or is_nil(ping.map) or
(not is_nil(ping.system) and ping.system.visible == false)
end)
{:ok, orphaned}
error ->
error
end
end
def create(ping), do: ping |> WandererApp.Api.MapPing.new()
def create!(ping), do: ping |> WandererApp.Api.MapPing.new!()
@@ -39,5 +67,23 @@ defmodule WandererApp.MapPingsRepo do
:ok
end
def destroy(_ping_id), do: :ok
@doc """
Deletes all pings for a given map. Use with caution - for cleanup purposes.
"""
def delete_all_for_map(map_id) do
case get_by_map(map_id) do
{:ok, pings} ->
Logger.info("[MapPingsRepo] Deleting #{length(pings)} pings for map #{map_id}")
Enum.each(pings, fn ping ->
Logger.info("[MapPingsRepo] Deleting ping #{ping.id} (type: #{ping.type})")
Ash.destroy!(ping)
end)
{:ok, length(pings)}
error ->
error
end
end
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

@@ -23,6 +23,7 @@ defmodule WandererAppWeb.Layouts do
attr :app_version, :string
attr :enabled, :boolean
attr :latest_post, :any, default: nil
def new_version_banner(assigns) do
~H"""
@@ -36,27 +37,89 @@ defmodule WandererAppWeb.Layouts do
>
<div class="hs-overlay-backdrop transition duration absolute left-0 top-0 w-full h-full bg-gray-900 bg-opacity-50 dark:bg-opacity-80 dark:bg-neutral-900">
</div>
<div class="absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] flex items-center">
<div class="rounded w-9 h-9 w-[80px] h-[66px] flex items-center justify-center relative z-20">
<.icon name="hero-chevron-double-right" class="w-9 h-9 mr-[-40px]" />
</div>
<div id="refresh-area">
<.live_component module={WandererAppWeb.MapRefresh} id="map-refresh" />
<div class="absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-6">
<div class="flex items-center">
<div class="rounded w-9 h-9 w-[80px] h-[66px] flex items-center justify-center relative z-20">
<.icon name="hero-chevron-double-right" class="w-9 h-9 mr-[-40px]" />
</div>
<div id="refresh-area">
<.live_component module={WandererAppWeb.MapRefresh} id="map-refresh" />
</div>
<div class="rounded h-[66px] flex items-center justify-center relative z-20">
<div class=" flex items-center w-[200px] h-full">
<.icon name="hero-chevron-double-left" class="w-9 h-9 mr-[20px]" />
<div class=" flex flex-col items-center justify-center h-full">
<div class="text-white text-nowrap text-sm [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
Update Required
</div>
<a
href="/changelog"
target="_blank"
class="text-sm link-secondary [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]"
>
What's new?
</a>
</div>
</div>
</div>
</div>
<div class="rounded h-[66px] flex items-center justify-center relative z-20">
<div class=" flex items-center w-[200px] h-full">
<.icon name="hero-chevron-double-left" class="w-9 h-9 mr-[20px]" />
<div class=" flex flex-col items-center justify-center h-full">
<div class="text-white text-nowrap text-sm [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
Update Required
<div class="flex flex-row gap-6 z-20">
<div
:if={@latest_post}
class="bg-gray-800/80 rounded-lg overflow-hidden min-w-[300px] backdrop-blur-sm border border-gray-700"
>
<a href={"/news/#{@latest_post.id}"} target="_blank" class="block group/post">
<div class="relative">
<img
src={@latest_post.cover_image_uri}
class="w-[300px] h-[140px] object-cover opacity-80 group-hover/post:opacity-100 transition-opacity"
/>
<div class="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black/70">
</div>
<div class="absolute top-2 left-2 flex items-center gap-1 bg-orange-500/90 px-2 py-0.5 rounded text-xs font-semibold">
<.icon name="hero-newspaper-solid" class="w-3 h-3" />
<span>Latest News</span>
</div>
<div class="absolute bottom-0 left-0 w-full p-3">
<% [first_part | rest] = String.split(@latest_post.title, ":", parts: 2) %>
<h3 class="text-white text-sm font-bold ccp-font [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
{first_part}
</h3>
<p
:if={rest != []}
class="text-gray-200 text-xs ccp-font text-ellipsis overflow-hidden whitespace-nowrap [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]"
>
{List.first(rest)}
</p>
</div>
</div>
</a>
</div>
<div class="bg-gray-800/80 rounded-lg p-4 min-w-[280px] backdrop-blur-sm border border-gray-700">
<div class="flex items-center gap-2 mb-3">
<.icon name="hero-gift-solid" class="w-5 h-5 text-green-400" />
<span class="text-white font-semibold text-sm [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
Support Wanderer
</span>
</div>
<div class="text-gray-300 text-xs mb-3 [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]">
Buy PLEX from the official EVE Online store using our promocode to support the development.
</div>
<div class="flex items-center gap-3">
<code class="bg-gray-900/60 px-2 py-1 rounded text-green-400 text-sm font-mono border border-gray-600">
WANDERER
</code>
<a
href="/changelog"
href="https://www.eveonline.com/plex"
target="_blank"
class="text-sm link-secondary [text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-green-400 hover:text-green-300 transition-colors"
>
What's new?
<span>Get PLEX</span>
<.icon name="hero-arrow-top-right-on-square-mini" class="w-4 h-4" />
</a>
</div>
</div>

View File

@@ -31,7 +31,7 @@
</div>
</aside>
<.new_version_banner app_version={@app_version} enabled={@map_subscriptions_enabled?} />
<.new_version_banner app_version={@app_version} enabled={true} latest_post={@latest_post} />
</div>
<.live_component module={WandererAppWeb.Alerts} id="notifications" view_flash={@flash} />

View File

@@ -455,7 +455,9 @@ defmodule WandererAppWeb.AccessListMemberAPIController do
end)
{:error, error} ->
Logger.warning("Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}")
Logger.warning(
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
)
end
end

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

@@ -41,12 +41,15 @@
<div class="absolute rounded-m top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black opacity-75 group-hover:opacity-25 transition-opacity duration-300">
</div>
<div class="absolute w-full bottom-2 p-4">
<% [first_part, second_part] = String.split(post.title, ":", parts: 2) %>
<% {first_part, second_part} = case String.split(post.title, ":", parts: 2) do
[first, second] -> {first, second}
[first] -> {first, nil}
end %>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">
{first_part}
</h3>
<p class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part || ""}
<p :if={second_part} class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part}
</p>
</div>
</div>

View File

@@ -115,7 +115,9 @@
{@post.description}
</h4>
<!--Post Content-->
{raw(@post.body)}
<div class="post-content">
{raw(@post.body)}
</div>
</div>
</div>
<!--/container-->

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

@@ -5,6 +5,8 @@ defmodule WandererAppWeb.AccessListsLive do
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})
@@ -25,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
@@ -39,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
@@ -93,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
@@ -324,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}
@@ -669,7 +687,7 @@ defmodule WandererAppWeb.AccessListsLive do
"""
end
slot(:option)
attr(:option, :any, required: true)
def search_member_item(assigns) do
~H"""
@@ -719,6 +737,17 @@ defmodule WandererAppWeb.AccessListsLive 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
@@ -742,7 +771,9 @@ defmodule WandererAppWeb.AccessListsLive do
end)
{:error, error} ->
Logger.warning("Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(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,50 @@
</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
: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>
<.link
disabled={@selected_acl_id == "" or not can_add_members?(@access_list, @current_user)}
class="btn mt-2 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>
</main>
</div>

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

@@ -15,6 +15,15 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 2xl:grid-cols-4 pb-6">
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<span class="text-gray-400 dark:text-gray-400">Maps Management</span>
<.link class="btn mt-2 w-full btn-neutral rounded-none" navigate={~p"/admin/maps"}>
<.icon name="hero-map-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Manage All Maps</h3>
</.link>
</div>
</div>
<div :if={@restrict_maps_creation?} class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.button class="mt-2" type="button" phx-click="create-map">
@@ -209,7 +218,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 +276,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

@@ -0,0 +1,273 @@
defmodule WandererAppWeb.AdminMapsLive do
@moduledoc """
Admin LiveView for managing all maps on the server.
Allows admins to view, edit, soft-delete, and restore maps regardless of ownership.
"""
use WandererAppWeb, :live_view
alias Phoenix.LiveView.AsyncResult
require Logger
@maps_per_page 20
@impl true
def mount(_params, %{"user_id" => user_id} = _session, socket)
when not is_nil(user_id) and is_connected?(socket) do
{:ok,
socket
|> assign(
maps: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @maps_per_page
)
|> load_maps_async()}
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(
maps: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @maps_per_page
)}
end
@impl true
def handle_params(params, _url, socket) when is_connected?(socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Maps")
|> assign(:selected_map, nil)
|> assign(:form, nil)
end
defp apply_action(socket, :edit, %{"id" => map_id}) do
case load_map_for_edit(map_id) do
{:ok, map} ->
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Edit Map")
|> assign(:selected_map, map)
|> assign(
:form,
map
|> AshPhoenix.Form.for_update(:update, forms: [auto?: true])
|> to_form()
)
|> load_owner_options()
{:error, _} ->
socket
|> put_flash(:error, "Map not found")
|> push_navigate(to: ~p"/admin/maps")
end
end
defp apply_action(socket, :view_acls, %{"id" => map_id}) do
case load_map_with_acls(map_id) do
{:ok, map} ->
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Map ACLs")
|> assign(:selected_map, map)
{:error, _} ->
socket
|> put_flash(:error, "Map not found")
|> push_navigate(to: ~p"/admin/maps")
end
end
# Data loading functions
defp load_maps_async(socket) do
socket
|> assign_async(:maps, fn -> load_all_maps() end)
end
defp load_all_maps do
case WandererApp.Api.Map.admin_all() do
{:ok, maps} ->
maps =
maps
|> Enum.sort_by(& &1.name, :asc)
{:ok, %{maps: maps}}
_ ->
{:ok, %{maps: []}}
end
end
defp load_map_for_edit(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
{:ok, map} = Ash.load(map, [:owner, :acls])
{:ok, map}
error ->
error
end
end
defp load_map_with_acls(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
{:ok, map} = Ash.load(map, acls: [:owner, :members])
{:ok, map}
error ->
error
end
end
defp load_owner_options(socket) do
case WandererApp.Api.Character.read() do
{:ok, characters} ->
options =
characters
|> Enum.map(fn c -> {c.name, c.id} end)
|> Enum.sort_by(&elem(&1, 0))
socket |> assign(:owner_options, options)
_ ->
socket |> assign(:owner_options, [])
end
end
# Event handlers
@impl true
def handle_event("search", %{"value" => term}, socket) do
{:noreply, socket |> assign(:search_term, term) |> assign(:page, 1)}
end
@impl true
def handle_event("toggle_deleted", _params, socket) do
{:noreply,
socket |> assign(:show_deleted, not socket.assigns.show_deleted) |> assign(:page, 1)}
end
@impl true
def handle_event("delete_map", %{"id" => map_id}, socket) do
case soft_delete_map(map_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Map marked as deleted")
|> load_maps_async()}
{:error, _} ->
{:noreply, socket |> put_flash(:error, "Failed to delete map")}
end
end
@impl true
def handle_event("restore_map", %{"id" => map_id}, socket) do
case restore_map(map_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Map restored successfully")
|> load_maps_async()}
{:error, _} ->
{:noreply, socket |> put_flash(:error, "Failed to restore map")}
end
end
@impl true
def handle_event("validate", %{"form" => params}, socket) do
form = AshPhoenix.Form.validate(socket.assigns.form, params)
{:noreply, assign(socket, form: form)}
end
@impl true
def handle_event("save", %{"form" => params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
{:ok, _map} ->
{:noreply,
socket
|> put_flash(:info, "Map updated successfully")
|> push_navigate(to: ~p"/admin/maps")}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("page", %{"page" => page}, socket) do
{:noreply, socket |> assign(:page, String.to_integer(page))}
end
@impl true
def handle_event(_event, _params, socket) do
{:noreply, socket}
end
# Helper functions
defp soft_delete_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
WandererApp.Api.Map.mark_as_deleted(map)
error ->
error
end
end
defp restore_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
WandererApp.Api.Map.restore(map)
error ->
error
end
end
def filter_maps(maps, search_term, show_deleted) do
maps
|> Enum.filter(fn map ->
(show_deleted or not map.deleted) and
(search_term == "" or
String.contains?(String.downcase(map.name || ""), String.downcase(search_term)) or
String.contains?(String.downcase(map.slug || ""), String.downcase(search_term)))
end)
end
def paginate(maps, page, per_page) do
maps
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
def total_pages(maps, per_page) do
max(1, ceil(length(maps) / per_page))
end
def format_date(nil), do: "-"
def format_date(datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
def owner_name(nil), do: "No owner"
def owner_name(%{name: name}), do: name
end

View File

@@ -0,0 +1,240 @@
<main class="w-full h-full col-span-2 lg:col-span-1 p-4 pl-20 overflow-auto">
<div class="page-content">
<div class="container-fluid px-[0.625rem]">
<!-- Header -->
<div class="grid grid-cols-1 pb-6">
<div class="md:flex items-center justify-between px-[2px]">
<h4 class="text-[18px] font-medium text-gray-800 mb-sm-0 grow dark:text-gray-100 mb-2 md:mb-0">
Admin - Maps Management
</h4>
<.link navigate={~p"/admin"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left-solid" class="w-4 h-4" /> Back to Admin
</.link>
</div>
</div>
<!-- Search and Filters -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600 mb-4">
<div class="card-body flex flex-row gap-4 items-center">
<div class="flex-1">
<input
type="text"
placeholder="Search by name or slug..."
value={@search_term}
phx-keyup="search"
phx-debounce="300"
name="search"
class="input input-bordered w-full"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="checkbox"
checked={@show_deleted}
phx-click="toggle_deleted"
/>
<span class="text-sm">Show deleted</span>
</label>
</div>
</div>
<!-- Maps Table -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.async_result :let={maps} assign={@maps}>
<:loading>
<div class="flex justify-center p-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
</:loading>
<:failed :let={reason}>
<div class="alert alert-error">{inspect(reason)}</div>
</:failed>
<% filtered_maps = filter_maps(maps, @search_term, @show_deleted) %>
<% paginated_maps = paginate(filtered_maps, @page, @per_page) %>
<.table id="admin-maps" rows={paginated_maps} class="!max-h-[60vh] !overflow-y-auto">
<:col :let={map} label="Name">
<div class="flex items-center gap-2">
<span class={if map.deleted, do: "line-through text-gray-500", else: ""}>
{map.name}
</span>
<span :if={map.deleted} class="badge badge-error badge-sm">Deleted</span>
</div>
</:col>
<:col :let={map} label="Slug">
<span class="text-sm text-gray-400">{map.slug}</span>
</:col>
<:col :let={map} label="Owner">
{owner_name(map.owner)}
</:col>
<:col :let={map} label="Created">
<span class="text-sm">{format_date(map.inserted_at)}</span>
</:col>
<:col :let={map} label="Scope">
<span class="badge badge-ghost badge-sm">{map.scope}</span>
</:col>
<:action :let={map}>
<.link
patch={~p"/admin/maps/#{map.id}/edit"}
class="btn btn-ghost btn-xs hover:text-white"
title="Edit"
>
<.icon name="hero-pencil-solid" class="w-4 h-4" />
</.link>
</:action>
<:action :let={map}>
<.link
patch={~p"/admin/maps/#{map.id}/acls"}
class="btn btn-ghost btn-xs hover:text-white"
title="View ACLs"
>
<.icon name="hero-shield-check-solid" class="w-4 h-4" />
</.link>
</:action>
<:action :let={map}>
<button
:if={not map.deleted}
phx-click="delete_map"
phx-value-id={map.id}
data={[confirm: "Are you sure you want to delete this map?"]}
class="btn btn-ghost btn-xs hover:text-red-500"
title="Delete"
>
<.icon name="hero-trash-solid" class="w-4 h-4" />
</button>
<button
:if={map.deleted}
phx-click="restore_map"
phx-value-id={map.id}
data={[confirm: "Are you sure you want to restore this map?"]}
class="btn btn-ghost btn-xs hover:text-green-500"
title="Restore"
>
<.icon name="hero-arrow-path-solid" class="w-4 h-4" />
</button>
</:action>
</.table>
<!-- Pagination -->
<div
:if={length(filtered_maps) > @per_page}
class="flex items-center justify-between mt-4"
>
<span class="text-sm text-gray-400">
Page {@page} of {total_pages(filtered_maps, @per_page)} ({length(filtered_maps)} maps)
</span>
<div class="flex gap-2">
<button
phx-click="page"
phx-value-page={max(1, @page - 1)}
disabled={@page <= 1}
class={"btn btn-sm btn-ghost " <> if(@page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="page"
phx-value-page={min(total_pages(filtered_maps, @per_page), @page + 1)}
disabled={@page >= total_pages(filtered_maps, @per_page)}
class={"btn btn-sm btn-ghost " <> if(@page >= total_pages(filtered_maps, @per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Empty state -->
<div :if={length(filtered_maps) == 0} class="text-center py-8 text-gray-400">
No maps found
</div>
</.async_result>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<.modal
:if={@live_action == :edit and not is_nil(@selected_map)}
title="Edit Map"
class="!w-[500px]"
id="edit_map_modal"
show
on_cancel={JS.patch(~p"/admin/maps")}
>
<.form :let={f} for={@form} phx-change="validate" phx-submit="save">
<.input type="text" field={f[:name]} label="Name" placeholder="Map name" />
<.input type="text" field={f[:slug]} label="Slug" placeholder="map-slug" />
<.input
type="textarea"
field={f[:description]}
label="Description"
placeholder="Description"
/>
<.input
type="select"
field={f[:scope]}
label="Scope"
options={[
{"Wormholes", :wormholes},
{"Stargates", :stargates},
{"None", :none},
{"All", :all}
]}
/>
<.input
type="select"
field={f[:owner_id]}
label="Owner"
options={@owner_options}
prompt="Select owner..."
/>
<div class="modal-action">
<.button type="submit" phx-disable-with="Saving...">
Save Changes
</.button>
</div>
</.form>
</.modal>
<!-- View ACLs Modal -->
<.modal
:if={@live_action == :view_acls and not is_nil(@selected_map)}
title={"ACLs for: #{@selected_map.name}"}
class="!w-[600px]"
id="view_acls_modal"
show
on_cancel={JS.patch(~p"/admin/maps")}
>
<div class="space-y-4">
<div :if={Enum.empty?(@selected_map.acls)} class="text-gray-400 text-center py-4">
No ACLs assigned to this map
</div>
<div :for={acl <- @selected_map.acls} class="card bg-base-200">
<div class="card-body p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold">{acl.name}</h3>
<p class="text-sm text-gray-400">{acl.description || "No description"}</p>
</div>
<div class="badge badge-ghost">
{length(acl.members)} members
</div>
</div>
<div class="text-sm mt-2">
<span class="text-gray-400">Owner:</span>
<span>{if acl.owner, do: acl.owner.name, else: "Unknown"}</span>
</div>
</div>
</div>
</div>
<div class="modal-action">
<.link patch={~p"/admin/maps"} class="btn btn-ghost">
Close
</.link>
</div>
</.modal>
</main>

View File

@@ -29,6 +29,34 @@
id="characters-list"
class="w-full h-full col-span-2 lg:col-span-1 p-4 pl-20 pb-20 overflow-auto"
>
<div class="flex items-center justify-between gap-4 px-4 py-2 mb-4 bg-stone-900/60 border border-stone-800 rounded">
<div class="flex items-center gap-3">
<.icon name="hero-gift-solid" class="w-4 h-4 text-green-400 flex-shrink-0" />
<span class="text-sm text-gray-300">
Support development by using promocode
<code class="ml-1 px-1.5 py-0.5 bg-stone-800 rounded text-green-400 text-xs font-mono">WANDERER</code>
<span class="ml-1">at official</span>
</span>
<a
href="https://store.eveonline.com/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-green-400 hover:text-green-300 transition-colors"
>
<span>EVE Online Store</span>
<.icon name="hero-arrow-top-right-on-square-mini" class="w-3 h-3" />
</a>
</div>
<a
href="https://wanderer.ltd/news"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1 text-sm text-white rounded bg-gradient-to-r from-stone-700 to-stone-600 hover:from-stone-600 hover:to-stone-500 transition-all duration-300 animate-pulse hover:animate-none"
>
<.icon name="hero-newspaper-solid" class="w-3.5 h-3.5" />
<span>Check Latest News</span>
</a>
</div>
<div
:if={@show_characters_add_alert}
role="alert"

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

@@ -81,12 +81,41 @@ defmodule WandererAppWeb.MapPingsEventHandler do
when not is_nil(main_character_id) do
{:ok, pings} = WandererApp.MapPingsRepo.get_by_map(map_id)
no_exisiting_pings =
# Filter out orphaned pings (system/character deleted or system hidden)
# These should not block new ping creation
valid_pings =
pings
|> Enum.filter(fn ping ->
not is_nil(ping.system) and not is_nil(ping.character) and
(is_nil(ping.system.visible) or ping.system.visible == true)
end)
existing_rally_pings =
valid_pings
|> Enum.filter(fn %{type: type} ->
type == 1
end)
|> Enum.empty?()
no_exisiting_pings = Enum.empty?(existing_rally_pings)
orphaned_count = length(pings) - length(valid_pings)
# Log detailed info about existing pings for debugging
if length(existing_rally_pings) > 0 do
ping_details =
existing_rally_pings
|> Enum.map(fn p ->
"id=#{p.id}, type=#{p.type}, system_id=#{inspect(p.system_id)}, character_id=#{inspect(p.character_id)}, inserted_at=#{p.inserted_at}"
end)
|> Enum.join("; ")
Logger.warning(
"add_ping BLOCKED: map_id=#{map_id}, existing_rally_pings=#{length(existing_rally_pings)}: [#{ping_details}]"
)
else
Logger.debug(
"add_ping check: map_id=#{map_id}, total_pings=#{length(pings)}, valid_pings=#{length(valid_pings)}, orphaned=#{orphaned_count}, rally_pings=0, can_create=true"
)
end
if no_exisiting_pings do
map_id
@@ -97,9 +126,16 @@ defmodule WandererAppWeb.MapPingsEventHandler do
character_id: main_character_id,
user_id: current_user.id
})
end
{:noreply, socket}
{:noreply, socket}
else
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "rally_point_exists",
message: "A rally point already exists on this map"
})}
end
end
def handle_ui_event(
@@ -117,6 +153,8 @@ defmodule WandererAppWeb.MapPingsEventHandler do
socket
)
when not is_nil(main_character_id) do
Logger.debug("handle_ui_event cancel_ping: id=#{id}, type=#{type}, map_id=#{map_id}")
map_id
|> WandererApp.Map.Server.cancel_ping(%{
id: id,
@@ -128,6 +166,80 @@ defmodule WandererAppWeb.MapPingsEventHandler do
{:noreply, socket}
end
# Catch add_ping when main_character_id is nil
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{main_character_id: nil}} = socket
) do
Logger.warning("add_ping blocked: main_character_id is nil")
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_main_character",
message: "Please select a main character to create pings"
})}
end
# Catch add_ping when has_tracked_characters? is false
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{has_tracked_characters?: false}} = socket
) do
Logger.warning("add_ping blocked: no tracked characters")
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_tracked_characters",
message: "Please add a tracked character to create pings"
})}
end
# Catch add_ping when subscription is not active
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{is_subscription_active?: false}} = socket
) do
Logger.warning("add_ping blocked: subscription not active")
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "subscription_inactive",
message: "Map subscription is not active"
})}
end
# Catch add_ping when user doesn't have update_system permission
def handle_ui_event(
"add_ping",
_event,
%{assigns: %{user_permissions: %{update_system: false}}} = socket
) do
Logger.warning("add_ping blocked: no update_system permission")
{:noreply,
socket
|> MapEventHandler.push_map_event("ping_blocked", %{
reason: "no_permission",
message: "You don't have permission to create pings on this map"
})}
end
# Catch cancel_ping failures with feedback
def handle_ui_event(
"cancel_ping",
_event,
%{assigns: %{main_character_id: nil}} = socket
) do
Logger.warning("cancel_ping blocked: main_character_id is nil")
{:noreply, socket}
end
def handle_ui_event(event, body, socket),
do: MapCoreEventHandler.handle_ui_event(event, body, socket)

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
@@ -363,8 +363,8 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
linked_sig_eve_id: nil
})
s
|> WandererApp.Api.MapSystemSignature.update_linked_system(%{
# Use the wrapper to log unlink operations
WandererApp.Map.Server.SignaturesImpl.update_signature_linked_system(s, %{
linked_system_id: nil
})
end)
@@ -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,

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