Compare commits

..

86 Commits

Author SHA1 Message Date
CI
6f0043205c chore: release version v1.90.0 2025-12-06 10:58:14 +00:00
Dmitry Popov
597741fa60 Merge pull request #567 from wanderer-industries/develop
Develop
2025-12-06 14:57:27 +04:00
Dmitry Popov
d313ae8cd2 fix(core): fixed clean up for linked signatures
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-12-04 11:33:42 +01:00
Dmitry Popov
06d5d8072e fix(core): fixed issue with default select mode
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-12-03 12:21:40 +01:00
CI
f2d112df5c chore: [skip ci] 2025-12-02 23:44:54 +00:00
CI
716604fa84 chore: release version v1.89.6 2025-12-02 23:44:54 +00:00
Dmitry Popov
cae958a1e6 Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-12-03 00:44:31 +01:00
Dmitry Popov
283b36c882 fix(kills): fixed zkb links (added "allow-popups-to-escape-sandbox" to CSP) 2025-12-03 00:44:23 +01:00
Dmitry Popov
051e71f1a6 Merge pull request #566 from guarzo/guarzo/sigapi
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
fix: apiV1 default fields updates
2025-12-03 00:20:13 +04:00
Guarzo
20a50e8db0 fix: apiV1 default fields updates 2025-12-02 17:55:05 +00:00
CI
79d7f7ce7d chore: [skip ci] 2025-12-02 12:46:26 +00:00
CI
6c4b65c446 chore: release version v1.89.5 2025-12-02 12:46:26 +00:00
Dmitry Popov
2b07af5e12 Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-12-02 13:45:57 +01:00
Dmitry Popov
d0901eecb4 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-02 13:45:51 +01:00
Dmitry Popov
ee85d29c54 chore: added tests 2025-12-02 13:45:47 +01:00
Dmitry Popov
a237d6513d Merge branch 'main' into develop 2025-12-02 13:37:13 +01:00
CI
02979588c1 chore: [skip ci] 2025-12-02 12:35:31 +00:00
CI
3abe40855f chore: release version v1.89.4 2025-12-02 12:35:30 +00:00
Dmitry Popov
d0d9418a89 fix(core): fixed acl character update issues 2025-12-02 13:34:55 +01:00
CI
3ce742eb01 chore: [skip ci] 2025-11-30 22:26:08 +00:00
CI
ae566fb907 chore: release version v1.89.3 2025-11-30 22:26:08 +00:00
Dmitry Popov
fa32c62f63 Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-11-30 23:25:48 +01:00
Dmitry Popov
6880be11c5 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-30 23:25:40 +01:00
Dmitry Popov
5289893264 fix(core): fixed tracking issues 2025-11-30 23:25:37 +01:00
CI
f15370a3df chore: [skip ci] 2025-11-30 18:07:05 +00:00
CI
cfac867c0a chore: release version v1.89.2 2025-11-30 18:07:05 +00:00
Dmitry Popov
f50ea40b15 chore: updated tests for tracking 2025-11-30 19:06:15 +01:00
CI
04b2d57081 chore: [skip ci] 2025-11-30 17:52:21 +00:00
CI
b235ea52e0 chore: release version v1.89.1 2025-11-30 17:52:21 +00:00
Dmitry Popov
2cb2dc526c Merge branch 'main' into develop
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
2025-11-30 18:51:58 +01:00
Dmitry Popov
f3c38ba62a fix(core): fixed tracking issues 2025-11-30 18:51:50 +01:00
CI
29473f2d3b chore: [skip ci] 2025-11-30 10:00:35 +00:00
CI
48654250e8 chore: release version v1.89.0 2025-11-30 10:00:35 +00:00
Aleksei Chichenkov
7aa24245b6 Merge pull request #564 from wanderer-industries/sig-panel
Sig panel
2025-11-30 13:00:08 +03:00
DanSylvest
6070d74684 feat: removed unnecessary command 2025-11-30 12:57:14 +03: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
CI
5c513f3e50 chore: [skip ci] 2025-11-29 19:13:30 +00:00
CI
5a980c6b89 chore: release version v1.88.13 2025-11-29 19:13:30 +00:00
Dmitry Popov
85c075c5a6 fix(core): fixed tracking issues 2025-11-29 20:12:54 +01:00
DanSylvest
f068afd16e Merge branch 'refs/heads/main' into sig-panel 2025-11-29 21:09:29 +03:00
DanSylvest
ac71b0af64 feat: rework wormholes reference 2025-11-29 21:07:48 +03:00
DanSylvest
5c515d6acd Merge remote-tracking branch 'leesolway/sig-panel-pr' into sig-panel
# Conflicts:
#	assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts
2025-11-29 17:32:35 +03:00
Dmitry Popov
4585c3a94b feat(core): Added several map scopes support (Wh, Hi, Low, Null, Pochven) 2025-11-29 14:36:45 +01:00
CI
cf2c27c961 chore: [skip ci] 2025-11-29 11:35:52 +00:00
CI
f8e403025c chore: release version v1.88.12 2025-11-29 11:35:52 +00: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
25fa7c07bc fix(core): fixed c4 -> ns connections auto size issues 2025-11-29 12:35:22 +01:00
Dmitry Popov
e7219e0eec chore: fixed compile warnings 2025-11-29 12:34:28 +01:00
CI
45130fcffa chore: [skip ci] 2025-11-29 09:16:34 +00:00
CI
5f75d4440d chore: release version v1.88.11 2025-11-29 09:16:34 +00:00
Dmitry Popov
34210f63e3 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-29 10:16:02 +01:00
Dmitry Popov
5f60fd4922 chore: fix tests workflow 2025-11-29 10:15:59 +01:00
CI
47ef7dda55 chore: [skip ci] 2025-11-29 00:15:17 +00:00
CI
0f3550a687 chore: release version v1.88.10 2025-11-29 00:15:17 +00:00
Dmitry Popov
8f242f3535 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-29 01:14:21 +01:00
Dmitry Popov
1ce39e5394 fix(core): fixed pings cleanup 2025-11-29 01:14:17 +01:00
CI
cca7b912aa chore: [skip ci] 2025-11-29 00:11:43 +00:00
CI
d939e32500 chore: release version v1.88.9 2025-11-29 00:11:43 +00:00
Dmitry Popov
97ebe66db5 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-29 01:11:04 +01:00
Dmitry Popov
f437fc4541 fix(core): fixed linked signatures cleanup 2025-11-29 01:11:01 +01:00
CI
6c65538450 chore: [skip ci] 2025-11-28 23:54:56 +00:00
CI
d566a74df4 chore: release version v1.88.8 2025-11-28 23:54:56 +00:00
Dmitry Popov
03e030a7d3 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-29 00:54:10 +01:00
Dmitry Popov
e738e1da9c fix(core): fixed pings issue 2025-11-29 00:54:07 +01:00
CI
972b3a6cbe chore: [skip ci] 2025-11-28 23:43:54 +00:00
CI
96b4a3077e chore: release version v1.88.7 2025-11-28 23:43:53 +00:00
Dmitry Popov
6b308e8a1e Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-29 00:43:16 +01:00
Dmitry Popov
d0874cbc6f fix(core): fixed tracking issues 2025-11-29 00:43:13 +01:00
CI
f106a51bf5 chore: [skip ci] 2025-11-28 22:50:24 +00:00
CI
dc47dc5f81 chore: release version v1.88.6 2025-11-28 22:50:24 +00:00
Dmitry Popov
dc81cffeea Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-28 23:49:53 +01:00
Dmitry Popov
5766fcf4d8 fix(core): fixed tracking issues 2025-11-28 23:49:48 +01:00
CI
c57a3b2cea chore: [skip ci] 2025-11-28 00:28:34 +00:00
CI
0c1fa8e79b chore: release version v1.88.5 2025-11-28 00:28:34 +00:00
Dmitry Popov
36cc91915c Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-11-28 01:27:30 +01:00
Dmitry Popov
bb644fde31 fix(core): fixed env errors 2025-11-28 01:27:26 +01:00
CI
269b54d382 chore: [skip ci] 2025-11-27 11:17:21 +00:00
CI
a9115cc653 chore: release version v1.88.4 2025-11-27 11:17:21 +00:00
Dmitry Popov
eeea7aee8b Merge pull request #563 from guarzo/guarzo/killsdefense
fix: defensive check for undefined excluded systems
2025-11-27 15:16:52 +04:00
Guarzo
700089e381 fix: defensive check for undefined excluded systems 2025-11-27 04:12:59 +00:00
CI
932935557c chore: [skip ci] 2025-11-26 22:42:01 +00:00
CI
2890a76cf2 chore: release version v1.88.3 2025-11-26 22:42:01 +00:00
Dmitry Popov
4ac9b2e2b7 chore: Updated mix version 2025-11-26 23:41:24 +01:00
Dmitry Popov
f92436f3f0 Merge branch 'develop' 2025-11-26 22:37:38 +01:00
Dmitry Popov
22d97cc99d fix(core): fixed env issues
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-26 22:18:02 +01:00
Lee Solway
be7bbe6872 Create a signature list panel + hook into live events 2025-10-04 12:04:02 +01:00
242 changed files with 13583 additions and 2136 deletions

View File

@@ -21,7 +21,7 @@ jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
@@ -35,17 +35,17 @@ jobs:
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Elixir/OTP
uses: erlef/setup-beam@v1
with:
elixir-version: ${{ env.ELIXIR_VERSION }}
otp-version: ${{ env.OTP_VERSION }}
- name: Cache Elixir dependencies
uses: actions/cache@v3
with:
@@ -54,12 +54,12 @@ jobs:
_build
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- name: Install Elixir dependencies
run: |
mix deps.get
mix deps.compile
- name: Check code formatting
id: format
run: |
@@ -71,42 +71,42 @@ jobs:
echo "count=1" >> $GITHUB_OUTPUT
fi
continue-on-error: true
- name: Compile code and capture warnings
id: compile
run: |
# Capture compilation output
output=$(mix compile 2>&1 || true)
echo "$output" > compile_output.txt
# Count warnings
warning_count=$(echo "$output" | grep -c "warning:" || echo "0")
# Check if compilation succeeded
if mix compile > /dev/null 2>&1; then
echo "status=✅ Success" >> $GITHUB_OUTPUT
else
echo "status=❌ Failed" >> $GITHUB_OUTPUT
fi
echo "warnings=$warning_count" >> $GITHUB_OUTPUT
echo "output<<EOF" >> $GITHUB_OUTPUT
echo "$output" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Setup database
run: |
mix ecto.create
mix ecto.migrate
- name: Run tests with coverage
id: tests
run: |
# Run tests with coverage
output=$(mix test --cover 2>&1 || true)
echo "$output" > test_output.txt
# Parse test results
if echo "$output" | grep -q "0 failures"; then
echo "status=✅ All Passed" >> $GITHUB_OUTPUT
@@ -115,16 +115,16 @@ jobs:
echo "status=❌ Some Failed" >> $GITHUB_OUTPUT
test_status="failed"
fi
# Extract test counts
test_line=$(echo "$output" | grep -E "[0-9]+ tests?, [0-9]+ failures?" | head -1 || echo "0 tests, 0 failures")
total_tests=$(echo "$test_line" | grep -o '[0-9]\+ tests\?' | grep -o '[0-9]\+' | head -1 || echo "0")
failures=$(echo "$test_line" | grep -o '[0-9]\+ failures\?' | grep -o '[0-9]\+' | head -1 || echo "0")
echo "total=$total_tests" >> $GITHUB_OUTPUT
echo "failures=$failures" >> $GITHUB_OUTPUT
echo "passed=$((total_tests - failures))" >> $GITHUB_OUTPUT
# Calculate success rate
if [ "$total_tests" -gt 0 ]; then
success_rate=$(echo "scale=1; ($total_tests - $failures) * 100 / $total_tests" | bc)
@@ -132,26 +132,26 @@ jobs:
success_rate="0"
fi
echo "success_rate=$success_rate" >> $GITHUB_OUTPUT
exit_code=$?
echo "exit_code=$exit_code" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Generate coverage report
id: coverage
run: |
# Generate coverage report with GitHub format
output=$(mix coveralls.github 2>&1 || true)
echo "$output" > coverage_output.txt
# Extract coverage percentage
coverage=$(echo "$output" | grep -o '[0-9]\+\.[0-9]\+%' | head -1 | sed 's/%//' || echo "0")
if [ -z "$coverage" ]; then
coverage="0"
fi
echo "percentage=$coverage" >> $GITHUB_OUTPUT
# Determine status
if (( $(echo "$coverage >= 80" | bc -l) )); then
echo "status=✅ Excellent" >> $GITHUB_OUTPUT
@@ -161,14 +161,14 @@ jobs:
echo "status=❌ Needs Improvement" >> $GITHUB_OUTPUT
fi
continue-on-error: true
- name: Run Credo analysis
id: credo
run: |
# Run Credo and capture output
output=$(mix credo --strict --format=json 2>&1 || true)
echo "$output" > credo_output.txt
# Try to parse JSON output
if echo "$output" | jq . > /dev/null 2>&1; then
issues=$(echo "$output" | jq '.issues | length' 2>/dev/null || echo "0")
@@ -183,12 +183,12 @@ jobs:
normal_issues="0"
low_issues="0"
fi
echo "total_issues=$issues" >> $GITHUB_OUTPUT
echo "high_issues=$high_issues" >> $GITHUB_OUTPUT
echo "normal_issues=$normal_issues" >> $GITHUB_OUTPUT
echo "low_issues=$low_issues" >> $GITHUB_OUTPUT
# Determine status
if [ "$issues" -eq 0 ]; then
echo "status=✅ Clean" >> $GITHUB_OUTPUT
@@ -198,24 +198,24 @@ jobs:
echo "status=❌ Needs Attention" >> $GITHUB_OUTPUT
fi
continue-on-error: true
- name: Run Dialyzer analysis
id: dialyzer
run: |
# Ensure PLT is built
mix dialyzer --plt
# Run Dialyzer and capture output
output=$(mix dialyzer --format=github 2>&1 || true)
echo "$output" > dialyzer_output.txt
# Count warnings and errors
warnings=$(echo "$output" | grep -c "warning:" || echo "0")
errors=$(echo "$output" | grep -c "error:" || echo "0")
echo "warnings=$warnings" >> $GITHUB_OUTPUT
echo "errors=$errors" >> $GITHUB_OUTPUT
# Determine status
if [ "$errors" -eq 0 ] && [ "$warnings" -eq 0 ]; then
echo "status=✅ Clean" >> $GITHUB_OUTPUT
@@ -225,7 +225,7 @@ jobs:
echo "status=❌ Has Errors" >> $GITHUB_OUTPUT
fi
continue-on-error: true
- name: Create test results summary
id: summary
run: |
@@ -236,11 +236,11 @@ jobs:
coverage_score=${{ steps.coverage.outputs.percentage }}
credo_score=$(echo "scale=0; (100 - ${{ steps.credo.outputs.total_issues }} * 2)" | bc | sed 's/^-.*$/0/')
dialyzer_score=$(echo "scale=0; (100 - ${{ steps.dialyzer.outputs.warnings }} * 2 - ${{ steps.dialyzer.outputs.errors }} * 10)" | bc | sed 's/^-.*$/0/')
overall_score=$(echo "scale=1; ($format_score + $compile_score + $test_score + $coverage_score + $credo_score + $dialyzer_score) / 6" | bc)
echo "overall_score=$overall_score" >> $GITHUB_OUTPUT
# Determine overall status
if (( $(echo "$overall_score >= 90" | bc -l) )); then
echo "overall_status=🌟 Excellent" >> $GITHUB_OUTPUT
@@ -252,7 +252,7 @@ jobs:
echo "overall_status=❌ Poor" >> $GITHUB_OUTPUT
fi
continue-on-error: true
- name: Find existing PR comment
if: github.event_name == 'pull_request'
id: find_comment
@@ -261,7 +261,7 @@ jobs:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: '## 🧪 Test Results Summary'
- name: Create or update PR comment
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v4
@@ -271,11 +271,11 @@ jobs:
edit-mode: replace
body: |
## 🧪 Test Results Summary
**Overall Quality Score: ${{ steps.summary.outputs.overall_score }}%** ${{ steps.summary.outputs.overall_status }}
### 📊 Metrics Dashboard
| Category | Status | Count | Details |
|----------|---------|-------|---------|
| 📝 **Code Formatting** | ${{ steps.format.outputs.status }} | ${{ steps.format.outputs.count }} issues | `mix format --check-formatted` |
@@ -284,50 +284,50 @@ jobs:
| 📊 **Coverage** | ${{ steps.coverage.outputs.status }} | ${{ steps.coverage.outputs.percentage }}% | `mix coveralls` |
| 🎯 **Credo** | ${{ steps.credo.outputs.status }} | ${{ steps.credo.outputs.total_issues }} issues | High: ${{ steps.credo.outputs.high_issues }}, Normal: ${{ steps.credo.outputs.normal_issues }}, Low: ${{ steps.credo.outputs.low_issues }} |
| 🔍 **Dialyzer** | ${{ steps.dialyzer.outputs.status }} | ${{ steps.dialyzer.outputs.errors }} errors, ${{ steps.dialyzer.outputs.warnings }} warnings | `mix dialyzer` |
### 🎯 Quality Gates
Based on the project's quality thresholds:
- **Compilation Warnings**: ${{ steps.compile.outputs.warnings }}/148 (limit: 148)
- **Credo Issues**: ${{ steps.credo.outputs.total_issues }}/87 (limit: 87)
- **Credo Issues**: ${{ steps.credo.outputs.total_issues }}/87 (limit: 87)
- **Dialyzer Warnings**: ${{ steps.dialyzer.outputs.warnings }}/161 (limit: 161)
- **Test Coverage**: ${{ steps.coverage.outputs.percentage }}%/50% (minimum: 50%)
- **Test Failures**: ${{ steps.tests.outputs.failures }}/0 (limit: 0)
<details>
<summary>📈 Progress Toward Goals</summary>
Target goals for the project:
- ✨ **Zero compilation warnings** (currently: ${{ steps.compile.outputs.warnings }})
- ✨ **≤10 Credo issues** (currently: ${{ steps.credo.outputs.total_issues }})
- ✨ **Zero Dialyzer warnings** (currently: ${{ steps.dialyzer.outputs.warnings }})
- ✨ **≥85% test coverage** (currently: ${{ steps.coverage.outputs.percentage }}%)
- ✅ **Zero test failures** (currently: ${{ steps.tests.outputs.failures }})
</details>
<details>
<summary>🔧 Quick Actions</summary>
To improve code quality:
```bash
# Fix formatting issues
mix format
# View detailed Credo analysis
mix credo --strict
# Check Dialyzer warnings
mix dialyzer
# Generate detailed coverage report
mix coveralls.html
```
</details>
---
🤖 *Auto-generated by GitHub Actions* • Updated: ${{ github.event.head_commit.timestamp }}
> **Note**: This comment will be updated automatically when new commits are pushed to this PR.
> **Note**: This comment will be updated automatically when new commits are pushed to this PR.

View File

@@ -2,6 +2,175 @@
<!-- changelog -->
## [v1.90.0](https://github.com/wanderer-industries/wanderer/compare/v1.89.6...v1.90.0) (2025-12-06)
### Features:
* core: Added several map scopes support (Wh, Hi, Low, Null, Pochven)
### Bug Fixes:
* core: fixed clean up for linked signatures
* core: fixed issue with default select mode
* apiV1 default fields updates
## [v1.89.6](https://github.com/wanderer-industries/wanderer/compare/v1.89.5...v1.89.6) (2025-12-02)
### Bug Fixes:
* kills: fixed zkb links (added "allow-popups-to-escape-sandbox" to CSP)
## [v1.89.5](https://github.com/wanderer-industries/wanderer/compare/v1.89.4...v1.89.5) (2025-12-02)
## [v1.89.4](https://github.com/wanderer-industries/wanderer/compare/v1.89.3...v1.89.4) (2025-12-02)
### Bug Fixes:
* core: fixed acl character update issues
## [v1.89.3](https://github.com/wanderer-industries/wanderer/compare/v1.89.2...v1.89.3) (2025-11-30)
### Bug Fixes:
* core: fixed tracking issues
## [v1.89.2](https://github.com/wanderer-industries/wanderer/compare/v1.89.1...v1.89.2) (2025-11-30)
## [v1.89.1](https://github.com/wanderer-industries/wanderer/compare/v1.89.0...v1.89.1) (2025-11-30)
### Bug Fixes:
* core: fixed tracking issues
## [v1.89.0](https://github.com/wanderer-industries/wanderer/compare/v1.88.13...v1.89.0) (2025-11-30)
### Features:
* removed unnecessary command
* rework wormholes reference
## [v1.88.13](https://github.com/wanderer-industries/wanderer/compare/v1.88.12...v1.88.13) (2025-11-29)
### Bug Fixes:
* core: fixed tracking issues
## [v1.88.12](https://github.com/wanderer-industries/wanderer/compare/v1.88.11...v1.88.12) (2025-11-29)
### Bug Fixes:
* core: fixed c4 -> ns connections auto size issues
## [v1.88.11](https://github.com/wanderer-industries/wanderer/compare/v1.88.10...v1.88.11) (2025-11-29)
## [v1.88.10](https://github.com/wanderer-industries/wanderer/compare/v1.88.9...v1.88.10) (2025-11-29)
### Bug Fixes:
* core: fixed pings cleanup
## [v1.88.9](https://github.com/wanderer-industries/wanderer/compare/v1.88.8...v1.88.9) (2025-11-29)
### Bug Fixes:
* core: fixed linked signatures cleanup
## [v1.88.8](https://github.com/wanderer-industries/wanderer/compare/v1.88.7...v1.88.8) (2025-11-28)
### Bug Fixes:
* core: fixed pings issue
## [v1.88.7](https://github.com/wanderer-industries/wanderer/compare/v1.88.6...v1.88.7) (2025-11-28)
### Bug Fixes:
* core: fixed tracking issues
## [v1.88.6](https://github.com/wanderer-industries/wanderer/compare/v1.88.5...v1.88.6) (2025-11-28)
### Bug Fixes:
* core: fixed tracking issues
## [v1.88.5](https://github.com/wanderer-industries/wanderer/compare/v1.88.4...v1.88.5) (2025-11-28)
### Bug Fixes:
* core: fixed env errors
## [v1.88.4](https://github.com/wanderer-industries/wanderer/compare/v1.88.3...v1.88.4) (2025-11-27)
### Bug Fixes:
* defensive check for undefined excluded systems
## [v1.88.3](https://github.com/wanderer-industries/wanderer/compare/v1.88.2...v1.88.3) (2025-11-26)
### Bug Fixes:
* core: fixed env issues
## [v1.88.1](https://github.com/wanderer-industries/wanderer/compare/v1.88.0...v1.88.1) (2025-11-26)

View File

@@ -32,8 +32,58 @@ 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 test --cover
MIX_ENV=test mix test --cover
unit-tests ut:
@echo "Running unit tests..."

View File

@@ -1,7 +1,7 @@
import { MarkdownComment } from '@/hooks/Mapper/components/mapInterface/components/Comments/components';
import { useEffect, useRef, useState } from 'react';
import { CommentType } from '@/hooks/Mapper/types';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { CommentType } from '@/hooks/Mapper/types';
import { useEffect, useMemo, useRef, useState } from 'react';
export interface CommentsProps {}
@@ -14,7 +14,9 @@ export const Comments = ({}: CommentsProps) => {
comments: { loadComments, comments, lastUpdateKey },
} = useMapRootState();
const [systemId] = selectedSystems;
const systemId = useMemo(() => {
return +selectedSystems[0];
}, [selectedSystems]);
const ref = useRef({ loadComments, systemId });
ref.current = { loadComments, systemId };

View File

@@ -3,7 +3,7 @@ import clsx from 'clsx';
import { PrimeIcons } from 'primereact/api';
import { MarkdownEditor } from '@/hooks/Mapper/components/mapInterface/components/MarkdownEditor';
import { useHotkey } from '@/hooks/Mapper/hooks';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { OutCommand } from '@/hooks/Mapper/types';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import classes from './CommentsEditor.module.scss';
@@ -19,7 +19,9 @@ export const CommentsEditor = ({}: CommentsEditorProps) => {
outCommand,
} = useMapRootState();
const [systemId] = selectedSystems;
const systemId = useMemo(() => {
return +selectedSystems[0];
}, [selectedSystems]);
const ref = useRef({ outCommand, systemId, textVal });
ref.current = { outCommand, systemId, textVal };

View File

@@ -31,7 +31,7 @@ export function useSystemKills({ systemId, outCommand, showAllVisible = false, s
storedSettings: { settingsKills },
} = useMapRootState();
const excludedSystems = useStableValue(settingsKills.excludedSystems);
const excludedSystems = useStableValue(settingsKills.excludedSystems ?? []);
const effectiveSystemIds = useMemo(() => {
if (showAllVisible) {

View File

@@ -9,6 +9,7 @@ import { MapContextMenu } from '@/hooks/Mapper/components/mapRootContent/compone
import { useSkipContextMenu } from '@/hooks/Mapper/hooks/useSkipContextMenu';
import { MapSettings } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings';
import { CharacterActivity } from '@/hooks/Mapper/components/mapRootContent/components/CharacterActivity';
import { WormholeSignaturesDialog } from '@/hooks/Mapper/components/mapRootContent/components/WormholeSignaturesDialog';
import { useCharacterActivityHandlers } from './hooks/useCharacterActivityHandlers';
import { TrackingDialog } from '@/hooks/Mapper/components/mapRootContent/components/TrackingDialog';
import { useMapEventListener } from '@/hooks/Mapper/events';
@@ -34,6 +35,7 @@ export const MapRootContent = ({}: MapRootContentProps) => {
const [showOnTheMap, setShowOnTheMap] = useState(false);
const [showMapSettings, setShowMapSettings] = useState(false);
const [showTrackingDialog, setShowTrackingDialog] = useState(false);
const [showWormholeList, setShowWormholeList] = useState(false);
/* Important Notice - this solution needs for use one instance of MapInterface */
const mapInterface = isReady ? <MapInterface /> : null;
@@ -41,6 +43,7 @@ export const MapRootContent = ({}: MapRootContentProps) => {
const handleShowOnTheMap = useCallback(() => setShowOnTheMap(true), []);
const handleShowMapSettings = useCallback(() => setShowMapSettings(true), []);
const handleShowTrackingDialog = useCallback(() => setShowTrackingDialog(true), []);
const handleShowWormholesReference = useCallback(() => setShowWormholeList(true), []);
useMapEventListener(event => {
if (event.name === Commands.showTracking) {
@@ -65,6 +68,7 @@ export const MapRootContent = ({}: MapRootContentProps) => {
onShowOnTheMap={handleShowOnTheMap}
onShowMapSettings={handleShowMapSettings}
onShowTrackingDialog={handleShowTrackingDialog}
onShowWormholesReference={handleShowWormholesReference}
additionalContent={<PingsInterface hasLeftOffset />}
/>
</div>
@@ -79,6 +83,7 @@ export const MapRootContent = ({}: MapRootContentProps) => {
onShowOnTheMap={handleShowOnTheMap}
onShowMapSettings={handleShowMapSettings}
onShowTrackingDialog={handleShowTrackingDialog}
onShowWormholesReference={handleShowWormholesReference}
/>
</div>
</Topbar>
@@ -93,6 +98,7 @@ export const MapRootContent = ({}: MapRootContentProps) => {
{showTrackingDialog && (
<TrackingDialog visible={showTrackingDialog} onHide={() => setShowTrackingDialog(false)} />
)}
<WormholeSignaturesDialog visible={showWormholeList} onHide={() => setShowWormholeList(false)} />
{hasOldSettings && <OldSettingsDialog />}
</Layout>

View File

@@ -12,9 +12,15 @@ export interface MapContextMenuProps {
onShowOnTheMap?: () => void;
onShowMapSettings?: () => void;
onShowTrackingDialog?: () => void;
onShowWormholesReference?: () => void;
}
export const MapContextMenu = ({ onShowOnTheMap, onShowMapSettings, onShowTrackingDialog }: MapContextMenuProps) => {
export const MapContextMenu = ({
onShowOnTheMap,
onShowMapSettings,
onShowTrackingDialog,
onShowWormholesReference,
}: MapContextMenuProps) => {
const {
outCommand,
storedSettings: { setInterfaceSettings },
@@ -52,6 +58,12 @@ export const MapContextMenu = ({ onShowOnTheMap, onShowMapSettings, onShowTracki
command: onShowOnTheMap,
visible: canTrackCharacters,
},
{
label: 'Wormholes Ref.',
icon: 'pi pi-bullseye',
command: onShowWormholesReference,
visible: canTrackCharacters,
},
{ separator: true, visible: true },
{
label: 'Settings',

View File

@@ -14,6 +14,7 @@ interface RightBarProps {
onShowOnTheMap?: () => void;
onShowMapSettings?: () => void;
onShowTrackingDialog?: () => void;
onShowWormholesReference?: () => void;
additionalContent?: ReactNode;
}
@@ -21,6 +22,7 @@ export const RightBar = ({
onShowOnTheMap,
onShowMapSettings,
onShowTrackingDialog,
onShowWormholesReference,
additionalContent,
}: RightBarProps) => {
const {
@@ -90,6 +92,16 @@ export const RightBar = ({
<i className="pi pi-hashtag"></i>
</button>
</WdTooltipWrapper>
<WdTooltipWrapper content="Wormholes Reference" position={TooltipPosition.left}>
<button
className="btn bg-transparent text-gray-400 hover:text-white border-transparent hover:bg-transparent py-2 h-auto min-h-auto"
type="button"
onClick={onShowWormholesReference}
>
<i className="pi pi-bullseye"></i>
</button>
</WdTooltipWrapper>
</div>
</>
)}

View File

@@ -1,8 +1,9 @@
import { createContext, useCallback, useContext, useRef, useState } from 'react';
import { OutCommand, TrackingCharacter } from '@/hooks/Mapper/types';
import { createContext, useCallback, useContext, useRef, useState, useEffect } from 'react';
import { Commands, OutCommand, TrackingCharacter } from '@/hooks/Mapper/types';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { IncomingEvent, WithChildren } from '@/hooks/Mapper/types/common.ts';
import { CommandInCharactersTrackingInfo } from '@/hooks/Mapper/types/commandsIn.ts';
import { useMapEventListener } from '@/hooks/Mapper/events';
type DiffTrackingInfo = { characterId: string; tracked: boolean };
@@ -122,6 +123,14 @@ export const TrackingProvider = ({ children }: WithChildren) => {
[outCommand],
);
// Listen for refresh_tracking_data event (triggered when ACL members change)
useMapEventListener(event => {
if (event.name === Commands.refreshTrackingData) {
loadTracking();
return true;
}
});
return (
<TrackingContext.Provider
value={{

View File

@@ -0,0 +1,170 @@
import { useMemo, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { WormholeDataRaw } from '@/hooks/Mapper/types';
import { RespawnTag, WHClassView } from '@/hooks/Mapper/components/ui-kit';
import { kgToTons } from '@/hooks/Mapper/utils/kgToTons.ts';
import { WORMHOLE_CLASS_STYLES, WORMHOLES_ADDITIONAL_INFO } from '@/hooks/Mapper/components/map/constants.ts';
import clsx from 'clsx';
import { InputText } from 'primereact/inputtext';
import { IconField } from 'primereact/iconfield';
import { InputIcon } from 'primereact/inputicon';
const renderSpawns = (w: WormholeDataRaw) => (
<div className="flex gap-1 flex-wrap">
{w.src.map(s => {
const group = s.split('-')[0];
const info = WORMHOLES_ADDITIONAL_INFO[group];
if (!info) {
return (
<span
key={s}
className="px-[4px] py-[1px] rounded bg-stone-800 text-stone-300 text-xs border border-stone-700"
>
{s}
</span>
);
}
const cls = WORMHOLE_CLASS_STYLES[String(info.wormholeClassID)] || '';
const label = `${info.shortName}`;
return (
<span
key={s}
className={clsx(cls, 'px-[4px] py-[1px] rounded text-xs border border-stone-700 bg-stone-900/40')}
>
{label}
</span>
);
})}
</div>
);
const renderName = (w: WormholeDataRaw) => (
<div className="flex items-center gap-2">
<WHClassView
whClassName={w.name}
noOffset
useShortTitle
classNameWh="overflow-hidden text-ellipsis whitespace-nowrap"
/>
</div>
);
const renderRespawn = (w: WormholeDataRaw) => (
<div className="flex gap-1 flex-wrap">
{w.respawn.map(r => (
<RespawnTag key={r} value={r} />
))}
</div>
);
export interface WormholeSignaturesDialogProps {
visible: boolean;
onHide: () => void;
}
export const WormholeSignaturesDialog = ({ visible, onHide }: WormholeSignaturesDialogProps) => {
const {
data: { wormholes },
} = useMapRootState();
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return wormholes;
return wormholes.filter(w => {
const destInfo = WORMHOLES_ADDITIONAL_INFO[w.dest];
const spawnsLabels = w.src
.map(s => {
const group = s.split('-')[0];
const info = WORMHOLES_ADDITIONAL_INFO[group];
if (!info) return s;
return `${info.title} ${info.shortName}`.trim();
})
.join(' ');
return [
w.name,
destInfo?.title,
destInfo?.shortName,
spawnsLabels,
String(w.total_mass),
String(w.max_mass_per_jump),
w.lifetime,
w.respawn.join(','),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
});
}, [wormholes, filter]);
return (
<Dialog
header="Wormholes Reference"
visible={visible}
draggable={false}
resizable={false}
className="w-[950px] h-[600px]"
onHide={onHide}
contentClassName="!p-0 flex flex-col h-full"
>
<div className="p-3 flex items-center justify-between gap-2 border-b border-stone-800">
<div className="font-semibold text-sm text-stone-200">Reference list of all wormhole types</div>
<IconField iconPosition="right">
<InputIcon
className={clsx('pi pi-times', {
['cursor-pointer text-stone-400 hover:text-stone-200']: filter,
['text-stone-700 opacity-50 cursor-default']: !filter,
})}
onClick={() => filter && setFilter('')}
role="button"
aria-label="Clear search"
aria-disabled={!filter}
title={filter ? 'Clear' : 'Nothing to clear'}
/>
<InputText className="w-64" placeholder="Search" value={filter} onChange={e => setFilter(e.target.value)} />
</IconField>
</div>
<div className="flex-1 p-3 overflow-x-hidden">
<DataTable value={filtered} size="small" scrollable scrollHeight="flex" stripedRows>
<Column header="Type" body={renderName} className="w-[160px]" bodyClassName="whitespace-normal break-words" />
<Column header="Spawns In" body={renderSpawns} bodyClassName="whitespace-normal break-words text-[13px]" />
<Column
field="lifetime"
header="Lifetime"
className="w-[90px]"
bodyClassName="whitespace-normal break-words text-[13px]"
/>
<Column
header="Total Mass"
className="w-[120px]"
body={(w: WormholeDataRaw) => kgToTons(w.total_mass)}
bodyClassName="whitespace-normal break-words text-[13px]"
/>
<Column
header="Max/jump"
className="w-[120px]"
body={(w: WormholeDataRaw) => kgToTons(w.max_mass_per_jump)}
bodyClassName="whitespace-normal break-words text-[13px]"
/>
<Column
header="Respawn"
className="w-[150px]"
body={renderRespawn}
bodyClassName="whitespace-normal break-words text-[13px]"
/>
</DataTable>
</div>
</Dialog>
);
};

View File

@@ -0,0 +1 @@
export * from './WormholeSignaturesDialog';

View File

@@ -0,0 +1,20 @@
import { Respawn } from '@/hooks/Mapper/types';
import clsx from 'clsx';
export const WORMHOLE_SPAWN_CLASSES_BG = {
[Respawn.static]: 'bg-lime-400/80 text-stone-950',
[Respawn.wandering]: 'bg-stone-800',
[Respawn.reverse]: 'bg-blue-400 text-stone-950',
};
type RespawnTagProps = { value: string };
export const RespawnTag = ({ value }: RespawnTagProps) => (
<span
className={clsx(
'px-[6px] py-[0px] rounded text-stone-300 text-[12px] font-[500] border border-stone-700',
WORMHOLE_SPAWN_CLASSES_BG[value as Respawn],
)}
>
{value}
</span>
);

View File

@@ -23,3 +23,4 @@ export * from './MenuItemWithInfo';
export * from './MarkdownTextViewer.tsx';
export * from './WdButton.tsx';
export * from './constants.ts';
export * from './RespawnTag';

View File

@@ -12,7 +12,7 @@ export const useCommandComments = () => {
}, []);
const removeComment = useCallback((data: CommandCommentRemoved) => {
ref.current.removeComment(data.solarSystemId.toString(), data.commentId);
ref.current.removeComment(data.solarSystemId, data.commentId);
}, []);
return { addComment, removeComment };

View File

@@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from 'react';
import { CommentSystem, CommentType, OutCommand, OutCommandHandler, UseCommentsData } from '@/hooks/Mapper/types';
import { useCallback, useRef, useState } from 'react';
interface UseCommentsProps {
outCommand: OutCommandHandler;
@@ -8,12 +8,12 @@ interface UseCommentsProps {
export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData => {
const [lastUpdateKey, setLastUpdateKey] = useState(0);
const commentBySystemsRef = useRef<Map<string, CommentSystem>>(new Map());
const commentBySystemsRef = useRef<Map<number, CommentSystem>>(new Map());
const ref = useRef({ outCommand });
ref.current = { outCommand };
const loadComments = useCallback(async (systemId: string) => {
const loadComments = useCallback(async (systemId: number) => {
let cSystem = commentBySystemsRef.current.get(systemId);
if (cSystem?.loading || cSystem?.loaded) {
return;
@@ -45,7 +45,7 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
setLastUpdateKey(x => x + 1);
}, []);
const addComment = useCallback((systemId: string, comment: CommentType) => {
const addComment = useCallback((systemId: number, comment: CommentType) => {
const cSystem = commentBySystemsRef.current.get(systemId);
if (cSystem) {
cSystem.comments.push(comment);
@@ -61,8 +61,9 @@ export const useComments = ({ outCommand }: UseCommentsProps): UseCommentsData =
setLastUpdateKey(x => x + 1);
}, []);
const removeComment = useCallback((systemId: string, commentId: string) => {
const removeComment = useCallback((systemId: number, commentId: string) => {
const cSystem = commentBySystemsRef.current.get(systemId);
console.log('cSystem', cSystem);
if (!cSystem) {
return;
}

View File

@@ -63,127 +63,122 @@ export const useMapRootHandlers = (ref: ForwardedRef<MapHandlers>) => {
const { pingAdded, pingCancelled } = useCommandPings();
const { characterActivityData, trackingCharactersData, userSettingsUpdated } = useCommandsActivity();
useImperativeHandle(
ref,
() => {
return {
command(type, data) {
switch (type) {
case Commands.init: // USED
mapInit(data as CommandInit);
break;
case Commands.addSystems: // USED
addSystems(data as CommandAddSystems);
break;
case Commands.updateSystems: // USED
updateSystems(data as CommandUpdateSystems);
break;
case Commands.removeSystems: // USED
removeSystems(data as CommandRemoveSystems);
break;
case Commands.addConnections: // USED
addConnections(data as CommandAddConnections);
break;
case Commands.removeConnections: // USED
removeConnections(data as CommandRemoveConnections);
break;
case Commands.updateConnection: // USED
updateConnection(data as CommandUpdateConnection);
break;
case Commands.charactersUpdated: // USED
charactersUpdated(data as CommandCharactersUpdated);
break;
case Commands.characterAdded: // USED
characterAdded(data as CommandCharacterAdded);
break;
case Commands.characterRemoved: // USED
characterRemoved(data as CommandCharacterRemoved);
break;
case Commands.characterUpdated: // USED
characterUpdated(data as CommandCharacterUpdated);
break;
case Commands.presentCharacters: // USED
presentCharacters(data as CommandPresentCharacters);
break;
case Commands.mapUpdated: // USED
mapUpdated(data as CommandMapUpdated);
break;
case Commands.routes:
mapRoutes(data as CommandRoutes);
break;
case Commands.userRoutes:
mapUserRoutes(data as CommandRoutes);
break;
useImperativeHandle(ref, () => {
return {
command(type, data) {
switch (type) {
case Commands.init: // USED
mapInit(data as CommandInit);
break;
case Commands.addSystems: // USED
addSystems(data as CommandAddSystems);
break;
case Commands.updateSystems: // USED
updateSystems(data as CommandUpdateSystems);
break;
case Commands.removeSystems: // USED
removeSystems(data as CommandRemoveSystems);
break;
case Commands.addConnections: // USED
addConnections(data as CommandAddConnections);
break;
case Commands.removeConnections: // USED
removeConnections(data as CommandRemoveConnections);
break;
case Commands.updateConnection: // USED
updateConnection(data as CommandUpdateConnection);
break;
case Commands.charactersUpdated: // USED
charactersUpdated(data as CommandCharactersUpdated);
break;
case Commands.characterAdded: // USED
characterAdded(data as CommandCharacterAdded);
break;
case Commands.characterRemoved: // USED
characterRemoved(data as CommandCharacterRemoved);
break;
case Commands.characterUpdated: // USED
characterUpdated(data as CommandCharacterUpdated);
break;
case Commands.presentCharacters: // USED
presentCharacters(data as CommandPresentCharacters);
break;
case Commands.mapUpdated: // USED
mapUpdated(data as CommandMapUpdated);
break;
case Commands.routes:
mapRoutes(data as CommandRoutes);
break;
case Commands.userRoutes:
mapUserRoutes(data as CommandRoutes);
break;
case Commands.signaturesUpdated: // USED
updateSystemSignatures(data as CommandSignaturesUpdated);
break;
case Commands.signaturesUpdated: // USED
updateSystemSignatures(data as CommandSignaturesUpdated);
break;
case Commands.linkSignatureToSystem: // USED
setTimeout(() => {
updateLinkSignatureToSystem(data as CommandLinkSignatureToSystem);
}, 200);
break;
case Commands.linkSignatureToSystem: // USED
setTimeout(() => {
updateLinkSignatureToSystem(data as CommandLinkSignatureToSystem);
}, 200);
break;
case Commands.centerSystem: // USED
// do nothing here
break;
case Commands.centerSystem: // USED
// do nothing here
break;
case Commands.selectSystem: // USED
// do nothing here
break;
case Commands.selectSystem: // USED
// do nothing here
break;
case Commands.killsUpdated:
// do nothing here
break;
case Commands.killsUpdated:
// do nothing here
break;
case Commands.detailedKillsUpdated:
updateDetailedKills(data as Record<string, DetailedKill[]>);
break;
case Commands.detailedKillsUpdated:
updateDetailedKills(data as Record<string, DetailedKill[]>);
break;
case Commands.characterActivityData:
characterActivityData(data as CommandCharacterActivityData);
break;
case Commands.characterActivityData:
characterActivityData(data as CommandCharacterActivityData);
break;
case Commands.trackingCharactersData:
trackingCharactersData(data as CommandTrackingCharactersData);
break;
case Commands.trackingCharactersData:
trackingCharactersData(data as CommandTrackingCharactersData);
break;
case Commands.updateActivity:
break;
case Commands.updateActivity:
break;
case Commands.updateTracking:
break;
case Commands.updateTracking:
break;
case Commands.userSettingsUpdated:
userSettingsUpdated(data as CommandUserSettingsUpdated);
break;
case Commands.userSettingsUpdated:
userSettingsUpdated(data as CommandUserSettingsUpdated);
break;
case Commands.systemCommentAdded:
addComment(data as CommandCommentAdd);
break;
case Commands.systemCommentAdded:
addComment(data as CommandCommentAdd);
break;
case Commands.systemCommentRemoved:
removeComment(data as CommandCommentRemoved);
break;
case Commands.systemCommentRemoved:
removeComment(data as CommandCommentRemoved);
break;
case Commands.pingAdded:
pingAdded(data as CommandPingAdded);
break;
case Commands.pingAdded:
pingAdded(data as CommandPingAdded);
break;
case Commands.pingCancelled:
pingCancelled(data as CommandPingCancelled);
break;
case Commands.pingCancelled:
pingCancelled(data as CommandPingCancelled);
break;
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;
}
default:
console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data);
break;
}
emitMapEvent({ name: type, data });
},
};
},
[],
);
emitMapEvent({ name: type, data });
},
};
}, []);
};

View File

@@ -13,9 +13,9 @@ export type CommentSystem = {
};
export interface UseCommentsData {
loadComments: (systemId: string) => Promise<void>;
addComment: (systemId: string, comment: CommentType) => void;
removeComment: (systemId: string, commentId: string) => void;
comments: Map<string, CommentSystem>;
loadComments: (systemId: number) => Promise<void>;
addComment: (systemId: number, comment: CommentType) => void;
removeComment: (systemId: number, commentId: string) => void;
comments: Map<number, CommentSystem>;
lastUpdateKey: number;
}

View File

@@ -38,6 +38,7 @@ export enum Commands {
updateTracking = 'update_tracking',
userSettingsUpdated = 'user_settings_updated',
showTracking = 'show_tracking',
refreshTrackingData = 'refresh_tracking_data',
pingAdded = 'ping_added',
pingCancelled = 'ping_cancelled',
}
@@ -74,6 +75,7 @@ export type Command =
| Commands.updateActivity
| Commands.updateTracking
| Commands.showTracking
| Commands.refreshTrackingData
| Commands.pingAdded
| Commands.pingCancelled;
@@ -131,7 +133,7 @@ export type CommandLinkSignatureToSystem = {
};
export type CommandLinkSignaturesUpdated = number;
export type CommandCommentAdd = {
solarSystemId: string;
solarSystemId: number;
comment: CommentType;
};
export type CommandCommentRemoved = {
@@ -145,6 +147,7 @@ export type CommandUserSettingsUpdated = {
};
export type CommandShowTracking = null;
export type CommandRefreshTrackingData = Record<string, never>;
export type CommandUpdateActivity = {
characterId: number;
systemId: number;
@@ -206,6 +209,7 @@ export interface CommandData {
[Commands.systemCommentRemoved]: CommandCommentRemoved;
[Commands.systemCommentsUpdated]: unknown;
[Commands.showTracking]: CommandShowTracking;
[Commands.refreshTrackingData]: CommandRefreshTrackingData;
[Commands.pingAdded]: CommandPingAdded;
[Commands.pingCancelled]: CommandPingCancelled;
}

View File

@@ -63,6 +63,7 @@ config :wanderer_app, WandererAppWeb.Endpoint,
]
config :wanderer_app,
environment: :dev,
dev_routes: true
# Do not include metadata nor timestamps in development logs

View File

@@ -1,5 +1,8 @@
import Config
# Set environment at compile time for modules using Application.compile_env
config :wanderer_app, environment: :prod
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,

View File

@@ -264,7 +264,7 @@ config :logger,
case config_env() do
:prod -> "info"
:dev -> "info"
:test -> "debug"
:test -> "warning"
end
)
)
@@ -432,7 +432,7 @@ config :wanderer_app, :license_manager,
config :wanderer_app, :sse,
enabled:
config_dir
|> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "true")
|> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "false")
|> String.to_existing_atom(),
max_connections_total:
config_dir |> get_int_from_path_or_env("WANDERER_SSE_MAX_CONNECTIONS", 1000),
@@ -447,6 +447,6 @@ config :wanderer_app, :sse,
config :wanderer_app, :external_events,
webhooks_enabled:
config_dir
|> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "true")
|> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "false")
|> String.to_existing_atom(),
webhook_timeout_ms: config_dir |> get_int_from_path_or_env("WANDERER_WEBHOOK_TIMEOUT_MS", 15000)

View File

@@ -1,5 +1,9 @@
import Config
# Disable Ash async operations in tests to ensure transactional safety
# This prevents Ash from spawning tasks that could bypass the Ecto sandbox
config :ash, :disable_async?, true
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
@@ -24,7 +28,11 @@ config :wanderer_app,
pubsub_client: Test.PubSubMock,
cached_info: WandererApp.CachedInfo.Mock,
character_api_disabled: false,
environment: :test
environment: :test,
map_subscriptions_enabled: false,
wanderer_kills_service_enabled: false,
sse: [enabled: false],
external_events: [webhooks_enabled: false]
# We don't run a server during test. If one is required,
# you can enable the server option below.

View File

@@ -16,6 +16,11 @@ defmodule WandererApp.Api.AccessList do
includes([:owner, :members])
default_fields([
:name,
:description
])
derive_filter?(true)
derive_sort?(true)
@@ -60,19 +65,17 @@ defmodule WandererApp.Api.AccessList do
# Added :api_key to the accepted attributes
accept [:name, :description, :owner_id, :api_key]
primary?(true)
argument :owner_id, :uuid, allow_nil?: false
change manage_relationship(:owner_id, :owner, on_lookup: :relate, on_no_match: nil)
end
update :update do
accept [:name, :description, :owner_id, :api_key]
primary?(true)
require_atomic? false
end
update :assign_owner do
accept [:owner_id]
require_atomic? false
end
end
@@ -81,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)
@@ -53,7 +61,11 @@ defmodule WandererApp.Api.AccessListMember do
:role
]
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
read :read_by_access_list do
argument(:access_list_id, :string, allow_nil?: false)
@@ -67,12 +79,14 @@ defmodule WandererApp.Api.AccessListMember do
update :block do
accept([])
require_atomic? false
change(set_attribute(:blocked, true))
end
update :unblock do
accept([])
require_atomic? false
change(set_attribute(:blocked, false))
end
@@ -83,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

@@ -0,0 +1,80 @@
defmodule WandererApp.Api.ActorHelpers do
@moduledoc """
Utilities for extracting actor information from Ash contexts.
Provides helper functions for working with ActorWithMap and extracting
user, map, and character information from various context formats.
"""
alias WandererApp.Api.ActorWithMap
@doc """
Extract map from actor or context.
Handles various context formats:
- Direct ActorWithMap struct
- Context map with :actor key
- Context map with :map key
- Ash.Resource.Change.Context struct
"""
def get_map(%{actor: %ActorWithMap{map: %{} = map}}), do: map
def get_map(%{map: %{} = map}), do: map
# Handle Ash.Resource.Change.Context struct
def get_map(%Ash.Resource.Change.Context{actor: %ActorWithMap{map: %{} = map}}), do: map
def get_map(%Ash.Resource.Change.Context{actor: _}), do: nil
def get_map(context) when is_map(context) do
# For plain maps, check private.actor
with private when is_map(private) <- Map.get(context, :private),
%ActorWithMap{map: %{} = map} <- Map.get(private, :actor) do
map
else
_ -> nil
end
end
def get_map(_), do: nil
@doc """
Extract user from actor.
Handles:
- ActorWithMap struct
- Direct user struct with :id field
"""
def get_user(%ActorWithMap{user: user}), do: user
def get_user(%{id: _} = user), do: user
def get_user(_), do: nil
@doc """
Get character IDs for the actor.
Used for ACL filtering to determine which resources the user can access.
Returns {:ok, list} or {:ok, []} if no characters found.
"""
def get_character_ids(%ActorWithMap{user: user}), do: get_character_ids(user)
def get_character_ids(%{characters: characters}) when is_list(characters) do
{:ok, Enum.map(characters, & &1.id)}
end
def get_character_ids(%{characters: %Ecto.Association.NotLoaded{}, id: user_id}) do
# Load characters from database
load_characters_by_id(user_id)
end
def get_character_ids(%{id: user_id}) do
# Fallback: load user with characters
load_characters_by_id(user_id)
end
def get_character_ids(_), do: {:ok, []}
defp load_characters_by_id(user_id) do
case WandererApp.Api.User.by_id(user_id, load: [:characters]) do
{:ok, user} -> {:ok, Enum.map(user.characters, & &1.id)}
_ -> {:ok, []}
end
end
end

View File

@@ -0,0 +1,15 @@
defmodule WandererApp.Api.ActorWithMap do
@moduledoc """
Wraps a user and map together as an actor for token-based authentication.
When API requests use Bearer token auth, the token identifies both the user
(map owner) and the map. This struct allows passing both through Ash's actor system.
"""
@enforce_keys [:user, :map]
defstruct [:user, :map]
def new(user, map) do
%__MODULE__{user: user, map: map}
end
end

View File

@@ -0,0 +1,40 @@
defmodule WandererApp.Api.Changes.InjectMapFromActor do
@moduledoc """
Ash change that injects map_id from the authenticated actor.
For token-based auth, the map is determined by the API token.
This change automatically sets map_id, so clients don't need to provide it.
"""
use Ash.Resource.Change
alias WandererApp.Api.ActorHelpers
@impl true
def change(changeset, _opts, context) do
case ActorHelpers.get_map(context) do
%{id: map_id} ->
Ash.Changeset.force_change_attribute(changeset, :map_id, map_id)
_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)
case map_id do
nil ->
Ash.Changeset.add_error(changeset,
field: :map_id,
message: "map_id is required (provide via token or attribute)"
)
_map_id ->
# map_id provided directly (internal calls, tests)
changeset
end
end
end
end

View File

@@ -69,11 +69,6 @@ defmodule WandererApp.Api.Character do
filter(expr(user_id == ^arg(:user_id) and deleted == false))
end
read :available_by_map do
argument(:map_id, :uuid, allow_nil?: false)
filter(expr(user_id == ^arg(:user_id) and deleted == false))
end
read :last_active do
argument(:from, :utc_datetime, allow_nil?: false)
@@ -100,6 +95,7 @@ defmodule WandererApp.Api.Character do
update :mark_as_deleted do
accept([])
require_atomic? false
change(atomic_update(:deleted, true))
change(atomic_update(:user_id, nil))
@@ -107,6 +103,7 @@ defmodule WandererApp.Api.Character do
update :update_online do
accept([:online])
require_atomic? false
end
update :update_location do

View File

@@ -33,7 +33,11 @@ defmodule WandererApp.Api.CorpWalletTransaction do
:ref_type
]
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
create :new do
accept [

View File

@@ -36,7 +36,11 @@ defmodule WandererApp.Api.License do
:expire_at
]
defaults [:read, :update, :destroy]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
create :create do
primary? true
@@ -58,12 +62,14 @@ defmodule WandererApp.Api.License do
update :invalidate do
accept([])
require_atomic? false
change(set_attribute(:is_valid, false))
end
update :set_valid do
accept([])
require_atomic? false
change(set_attribute(:is_valid, true))
end

View File

@@ -8,9 +8,13 @@ defmodule WandererApp.Api.Map do
alias Ash.Resource.Change.Builtins
require Logger
postgres do
repo(WandererApp.Repo)
table("maps_v1")
migration_defaults scopes: "'{wormholes}'"
end
json_api do
@@ -44,6 +48,7 @@ defmodule WandererApp.Api.Map do
code_interface do
define(:available, action: :available)
define(:get_map_by_slug, action: :by_slug, args: [:slug])
define(:by_api_key, action: :by_api_key, args: [:api_key])
define(:new, action: :new)
define(:create, action: :create)
define(:update, action: :update)
@@ -54,6 +59,7 @@ defmodule WandererApp.Api.Map do
define(:mark_as_deleted, action: :mark_as_deleted)
define(:update_api_key, action: :update_api_key)
define(:toggle_webhooks, action: :toggle_webhooks)
define(:toggle_sse, action: :toggle_sse)
define(:by_id,
get_by: [:id],
@@ -90,22 +96,35 @@ defmodule WandererApp.Api.Map do
filter expr(slug == ^arg(:slug))
end
read :by_api_key do
get? true
argument :api_key, :string, allow_nil?: false
prepare WandererApp.Api.Preparations.SecureApiKeyLookup
end
read :available do
prepare WandererApp.Api.Preparations.FilterMapsByRoles
end
create :new do
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id]
primary?(true)
accept [
:name,
:slug,
:description,
:scope,
:scopes,
:only_tracked_characters,
:owner_id,
:sse_enabled
]
argument :owner_id, :uuid, allow_nil?: false
primary?(true)
argument :create_default_acl, :boolean, allow_nil?: true
argument :acls, {:array, :uuid}, allow_nil?: true
argument :acls_text_input, :string, allow_nil?: true
argument :scope_text_input, :string, allow_nil?: true
argument :acls_empty_selection, :string, allow_nil?: true
change manage_relationship(:owner_id, :owner, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:acls, type: :append_and_remove)
change WandererApp.Api.Changes.SlugifyName
end
@@ -113,7 +132,17 @@ defmodule WandererApp.Api.Map do
update :update do
primary? true
require_atomic? false
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id]
accept [
:name,
:slug,
:description,
:scope,
:scopes,
:only_tracked_characters,
:owner_id,
:sse_enabled
]
argument :owner_id_text_input, :string, allow_nil?: true
argument :acls_text_input, :string, allow_nil?: true
@@ -128,6 +157,9 @@ defmodule WandererApp.Api.Map do
)
change WandererApp.Api.Changes.SlugifyName
# Validate subscription when enabling SSE
validate &validate_sse_subscription/2
end
update :update_acls do
@@ -142,33 +174,46 @@ defmodule WandererApp.Api.Map do
update :assign_owner do
accept [:owner_id]
require_atomic? false
end
update :update_hubs do
accept [:hubs]
require_atomic? false
end
update :update_options do
accept [:options]
require_atomic? false
end
update :mark_as_deleted do
accept([])
require_atomic? false
change(set_attribute(:deleted, true))
end
update :update_api_key do
accept [:public_api_key]
require_atomic? false
end
update :toggle_webhooks do
accept [:webhooks_enabled]
require_atomic? false
end
update :toggle_sse do
require_atomic? false
accept [:sse_enabled]
# Validate subscription when enabling SSE
validate &validate_sse_subscription/2
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
@@ -184,9 +229,14 @@ defmodule WandererApp.Api.Map do
description =
Ash.Changeset.get_attribute(changeset, :description) || source_map.description
# Use provided scopes or fall back to source map scopes
scopes =
Ash.Changeset.get_attribute(changeset, :scopes) || source_map.scopes
changeset
|> Ash.Changeset.change_attribute(:description, description)
|> Ash.Changeset.change_attribute(:scope, source_map.scope)
|> Ash.Changeset.change_attribute(:scopes, scopes)
|> Ash.Changeset.change_attribute(
:only_tracked_characters,
source_map.only_tracked_characters
@@ -312,12 +362,37 @@ defmodule WandererApp.Api.Map do
public?(true)
end
attribute :sse_enabled, :boolean do
default(false)
allow_nil?(false)
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
identities do
identity :unique_slug, [:slug]
identity :unique_public_api_key, [:public_api_key]
end
relationships do
@@ -344,4 +419,49 @@ defmodule WandererApp.Api.Map do
public? false
end
end
# SSE Subscription Validation
#
# This validation ensures that SSE can only be enabled when:
# 1. SSE is being disabled (always allowed)
# 2. Map is being created (skip validation, will be checked on first update)
# 3. Community Edition mode (always allowed)
# 4. Enterprise mode with active subscription
defp validate_sse_subscription(changeset, _context) do
sse_enabled = Ash.Changeset.get_attribute(changeset, :sse_enabled)
map_id = changeset.data.id
subscriptions_enabled = WandererApp.Env.map_subscriptions_enabled?()
cond do
# Not enabling SSE - no validation needed
not sse_enabled ->
:ok
# Map creation (no ID yet) - skip validation
is_nil(map_id) ->
:ok
# Community Edition mode - always allow
not subscriptions_enabled ->
:ok
# Enterprise mode - check subscription
true ->
validate_active_subscription(map_id)
end
end
defp validate_active_subscription(map_id) do
case WandererApp.Map.is_subscription_active?(map_id) do
{:ok, true} ->
:ok
{:ok, false} ->
{:error, field: :sse_enabled, message: "Active subscription required to enable SSE"}
{:error, reason} ->
Logger.error("Error checking subscription status: #{inspect(reason)}")
{:error, field: :sse_enabled, message: "Unable to verify subscription status"}
end
end
end

View File

@@ -61,7 +61,11 @@ defmodule WandererApp.Api.MapAccessList do
:access_list_id
]
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
read :read_by_map do
argument(:map_id, :string, allow_nil?: false)

View File

@@ -27,7 +27,11 @@ defmodule WandererApp.Api.MapChainPassages do
:solar_system_target_id
]
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
create :new do
accept [
@@ -40,12 +44,6 @@ defmodule WandererApp.Api.MapChainPassages do
]
primary?(true)
argument :map_id, :uuid, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
action :by_map_id, {:array, :struct} do

View File

@@ -27,6 +27,11 @@ defmodule WandererApp.Api.MapCharacterSettings do
includes([:map, :character])
default_fields([
:tracked,
:followed
])
derive_filter?(true)
derive_sort?(true)
@@ -81,12 +86,6 @@ defmodule WandererApp.Api.MapCharacterSettings do
:character_id,
:tracked
]
argument :map_id, :uuid, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
read :by_map_filtered do
@@ -134,6 +133,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
require_atomic? false
accept([
:tracked,
:followed,
:ship,
:ship_name,
:ship_item_id,
@@ -145,8 +146,7 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :track do
accept [:map_id, :character_id]
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
require_atomic? false
# Load the record first
load do
@@ -159,8 +159,7 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :untrack do
accept [:map_id, :character_id]
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
require_atomic? false
# Load the record first
load do
@@ -173,8 +172,7 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :follow do
accept [:map_id, :character_id]
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
require_atomic? false
# Load the record first
load do
@@ -187,8 +185,7 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :unfollow do
accept [:map_id, :character_id]
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
require_atomic? false
# Load the record first
load do
@@ -227,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

@@ -4,7 +4,8 @@ defmodule WandererApp.Api.MapConnection do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource]
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
postgres do
repo(WandererApp.Repo)
@@ -21,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)
@@ -73,7 +87,56 @@ defmodule WandererApp.Api.MapConnection do
:custom_info
]
defaults [:create, :read, :update, :destroy]
create :create do
primary? true
accept [
:map_id,
:solar_system_source,
:solar_system_target,
:type,
:ship_size_type,
:mass_status,
:time_status,
:wormhole_type,
:count_of_passage,
:locked,
:custom_info
]
# Inject map_id from token
change WandererApp.Api.Changes.InjectMapFromActor
end
read :read do
primary? true
# Security: Filter to only connections from actor's map
prepare WandererApp.Api.Preparations.FilterConnectionsByActorMap
end
update :update do
primary? true
accept [
:solar_system_source,
:solar_system_target,
:type,
:ship_size_type,
:mass_status,
:time_status,
:wormhole_type,
:count_of_passage,
:locked,
:custom_info
]
require_atomic? false
end
destroy :destroy do
primary? true
end
read :read_by_map do
argument(:map_id, :string, allow_nil?: false)
@@ -110,45 +173,57 @@ defmodule WandererApp.Api.MapConnection do
update :update_mass_status do
accept [:mass_status]
require_atomic? false
end
update :update_time_status do
accept [:time_status]
require_atomic? false
end
update :update_ship_size_type do
accept [:ship_size_type]
require_atomic? false
end
update :update_locked do
accept [:locked]
require_atomic? false
end
update :update_custom_info do
accept [:custom_info]
require_atomic? false
end
update :update_type do
accept [:type]
require_atomic? false
end
update :update_wormhole_type do
accept [:wormhole_type]
require_atomic? false
end
end
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
@@ -161,7 +236,7 @@ defmodule WandererApp.Api.MapConnection do
# 6 - EOL 48h
attribute :time_status, :integer do
default(0)
public? true
allow_nil?(true)
end
@@ -172,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
@@ -181,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

@@ -30,7 +30,11 @@ defmodule WandererApp.Api.MapInvite do
:token
]
defaults [:read, :update, :destroy]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
create :new do
accept [
@@ -41,10 +45,6 @@ defmodule WandererApp.Api.MapInvite do
]
primary?(true)
argument :map_id, :uuid, allow_nil?: true
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
end
read :by_map do

View File

@@ -3,7 +3,8 @@ defmodule WandererApp.Api.MapPing do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer
data_layer: AshPostgres.DataLayer,
primary_read_warning?: false
postgres do
repo(WandererApp.Repo)
@@ -36,7 +37,18 @@ defmodule WandererApp.Api.MapPing do
:message
]
defaults [:read, :update, :destroy]
defaults [:destroy]
update :update do
require_atomic? false
end
read :read do
primary? true
# Security: Filter to only pings from actor's map
prepare WandererApp.Api.Preparations.FilterPingsByActorMap
end
create :new do
accept [
@@ -48,14 +60,6 @@ defmodule WandererApp.Api.MapPing do
]
primary?(true)
argument :map_id, :uuid, allow_nil?: false
argument :system_id, :uuid, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
change manage_relationship(:map_id, :map, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
read :by_map do

View File

@@ -65,7 +65,11 @@ defmodule WandererApp.Api.MapSolarSystem do
:sun_type_id
]
defaults [:read, :destroy, :update]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
create :create do
primary? true

View File

@@ -24,7 +24,11 @@ defmodule WandererApp.Api.MapSolarSystemJumps do
:to_solar_system_id
]
defaults [:read, :destroy, :update]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
create :create do
primary? true

View File

@@ -45,7 +45,11 @@ defmodule WandererApp.Api.MapState do
:connections_start_time
]
defaults [:read, :update, :destroy]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
create :create do
primary? true

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)
@@ -62,7 +71,11 @@ defmodule WandererApp.Api.MapSubscription do
:auto_renew?
]
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
read :all_active do
prepare build(sort: [updated_at: :asc], load: [:map])
@@ -88,32 +101,39 @@ defmodule WandererApp.Api.MapSubscription do
update :update_plan do
accept [:plan]
require_atomic? false
end
update :update_characters_limit do
accept [:characters_limit]
require_atomic? false
end
update :update_hubs_limit do
accept [:hubs_limit]
require_atomic? false
end
update :update_active_till do
accept [:active_till]
require_atomic? false
end
update :update_auto_renew do
accept [:auto_renew?]
require_atomic? false
end
update :cancel do
accept([])
require_atomic? false
change(set_attribute(:status, :cancelled))
end
update :expire do
accept([])
require_atomic? false
change(set_attribute(:status, :expired))
end
@@ -124,6 +144,7 @@ defmodule WandererApp.Api.MapSubscription do
attribute :plan, :atom do
default "alpha"
public? true
constraints(
one_of: [
@@ -139,6 +160,7 @@ defmodule WandererApp.Api.MapSubscription do
attribute :status, :atom do
default "active"
public? true
constraints(
one_of: [
@@ -153,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

@@ -24,16 +24,12 @@ defmodule WandererApp.Api.MapSystem do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource]
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
postgres do
repo(WandererApp.Repo)
table("map_system_v1")
custom_indexes do
# Partial index for efficient visible systems query
index [:map_id], where: "visible = true", name: "map_system_v1_map_id_visible_index"
end
end
json_api do
@@ -70,10 +66,7 @@ defmodule WandererApp.Api.MapSystem do
define(:upsert, action: :upsert)
define(:destroy, action: :destroy)
define(:by_id,
get_by: [:id],
action: :read
)
define :by_id, action: :get_by_id, args: [:id], get?: true
define(:by_solar_system_id,
get_by: [:solar_system_id],
@@ -103,6 +96,7 @@ defmodule WandererApp.Api.MapSystem do
define(:update_status, action: :update_status)
define(:update_tag, action: :update_tag)
define(:update_temporary_name, action: :update_temporary_name)
define(:update_custom_name, action: :update_custom_name)
define(:update_labels, action: :update_labels)
define(:update_linked_sig_eve_id, action: :update_linked_sig_eve_id)
define(:update_position, action: :update_position)
@@ -128,7 +122,56 @@ defmodule WandererApp.Api.MapSystem do
:linked_sig_eve_id
]
defaults [:create, :update, :destroy]
create :create do
primary? true
accept [
:map_id,
:name,
:solar_system_id,
:position_x,
:position_y,
:status,
:visible,
:locked,
:custom_name,
:description,
:tag,
:temporary_name,
:labels,
:added_at,
:linked_sig_eve_id
]
# Inject map_id from token
change WandererApp.Api.Changes.InjectMapFromActor
end
update :update do
primary? true
require_atomic? false
# Note: name and solar_system_id are not in accept
# - solar_system_id should be immutable (identifier)
# - name has allow_nil? false which makes it required in JSON:API
accept [
:position_x,
:position_y,
:status,
:visible,
:locked,
:custom_name,
:description,
:tag,
:temporary_name,
:labels,
:linked_sig_eve_id
]
end
destroy :destroy do
primary? true
end
create :upsert do
primary? false
@@ -158,6 +201,9 @@ defmodule WandererApp.Api.MapSystem do
read :read do
primary?(true)
# Security: Filter to only systems from actor's map
prepare WandererApp.Api.Preparations.FilterSystemsByActorMap
pagination offset?: true,
default_limit: 100,
max_page_size: 500,
@@ -165,6 +211,11 @@ defmodule WandererApp.Api.MapSystem do
required?: false
end
read :get_by_id do
argument(:id, :string, allow_nil?: false)
filter(expr(id == ^arg(:id)))
end
read :read_all_by_map do
argument(:map_id, :string, allow_nil?: false)
filter(expr(map_id == ^arg(:map_id)))
@@ -186,44 +237,59 @@ defmodule WandererApp.Api.MapSystem do
update :update_name do
accept [:name]
require_atomic? false
end
update :update_description do
accept [:description]
require_atomic? false
end
update :update_locked do
accept [:locked]
require_atomic? false
end
update :update_status do
accept [:status]
require_atomic? false
end
update :update_tag do
accept [:tag]
require_atomic? false
end
update :update_temporary_name do
accept [:temporary_name]
require_atomic? false
end
update :update_custom_name do
accept [:custom_name]
require_atomic? false
end
update :update_labels do
accept [:labels]
require_atomic? false
end
update :update_position do
accept [:position_x, :position_y]
require_atomic? false
change(set_attribute(:visible, true))
end
update :update_linked_sig_eve_id do
accept [:linked_sig_eve_id]
require_atomic? false
end
update :update_visible do
accept [:visible]
require_atomic? false
end
end

View File

@@ -19,6 +19,10 @@ defmodule WandererApp.Api.MapSystemComment do
:character
])
default_fields([
:text
])
routes do
base("/map_system_comments")
@@ -59,12 +63,6 @@ defmodule WandererApp.Api.MapSystemComment do
:character_id,
:text
]
argument :system_id, :uuid, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
read :by_system_id do
@@ -79,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)
@@ -111,10 +125,6 @@ defmodule WandererApp.Api.MapSystemSignature do
:custom_info,
:deleted
]
argument :system_id, :uuid, allow_nil?: false
change manage_relationship(:system_id, :system, on_lookup: :relate, on_no_match: nil)
end
update :update do
@@ -139,14 +149,17 @@ defmodule WandererApp.Api.MapSystemSignature do
update :update_linked_system do
accept [:linked_system_id]
require_atomic? false
end
update :update_type do
accept [:type]
require_atomic? false
end
update :update_group do
accept [:group]
require_atomic? false
end
read :by_system_id do
@@ -185,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)
@@ -122,13 +137,6 @@ defmodule WandererApp.Api.MapSystemStructure do
:status,
:end_time
]
argument :system_id, :uuid, allow_nil?: false
change manage_relationship(:system_id, :system,
on_lookup: :relate,
on_no_match: nil
)
end
update :update do
@@ -158,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

@@ -29,7 +29,11 @@ defmodule WandererApp.Api.MapTransaction do
:amount
]
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
read :by_map do
argument(:map_id, :string, allow_nil?: false)

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")
@@ -53,22 +60,30 @@ defmodule WandererApp.Api.MapUserSettings do
:settings
]
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
update :update_settings do
accept [:settings]
require_atomic? false
end
update :update_main_character do
accept [:main_character_eve_id]
require_atomic? false
end
update :update_following_character do
accept [:following_character_eve_id]
require_atomic? false
end
update :update_hubs do
accept [:hubs]
require_atomic? false
end
end
@@ -77,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

@@ -58,6 +58,8 @@ defmodule WandererApp.Api.MapWebhookSubscription do
:consecutive_failures,
:secret
]
require_atomic? false
end
read :by_map do

View File

@@ -0,0 +1,64 @@
defmodule WandererApp.Api.Preparations.FilterByActorMap do
@moduledoc """
Shared filtering logic for actor map context.
Filters queries to only return resources belonging to the actor's map.
Used by preparations for MapSystem, MapConnection, and MapPing resources.
"""
require Ash.Query
alias WandererApp.Api.ActorHelpers
@doc """
Filter a query by the actor's map context.
If a map is found in the context, filters the query to only return
resources where map_id matches. If no map context exists, returns
a query that will return no results.
## Parameters
* `query` - The Ash query to filter
* `context` - The Ash context containing actor/map information
* `resource_name` - Name of the resource for telemetry (atom)
## Examples
iex> query = Ash.Query.new(WandererApp.Api.MapSystem)
iex> context = %{map: %{id: "map-123"}}
iex> result = FilterByActorMap.filter_by_map(query, context, :map_system)
# Returns query filtered by map_id == "map-123"
"""
def filter_by_map(query, context, resource_name) do
case ActorHelpers.get_map(context) do
%{id: map_id} ->
emit_telemetry(resource_name, map_id)
Ash.Query.filter(query, map_id == ^map_id)
nil ->
emit_telemetry_no_context(resource_name)
Ash.Query.filter(query, false)
_other ->
emit_telemetry_no_context(resource_name)
Ash.Query.filter(query, false)
end
end
defp emit_telemetry(resource_name, map_id) do
:telemetry.execute(
[:wanderer_app, :ash, :preparation, :filter_by_map],
%{count: 1},
%{resource: resource_name, map_id: map_id}
)
end
defp emit_telemetry_no_context(resource_name) do
:telemetry.execute(
[:wanderer_app, :ash, :preparation, :filter_by_map, :no_context],
%{count: 1},
%{resource: resource_name}
)
end
end

View File

@@ -0,0 +1,17 @@
defmodule WandererApp.Api.Preparations.FilterConnectionsByActorMap do
@moduledoc """
Ash preparation that filters connections to only those from the actor's map.
For token-based auth, this ensures the API only returns connections
from the map associated with the token.
"""
use Ash.Resource.Preparation
alias WandererApp.Api.Preparations.FilterByActorMap
@impl true
def prepare(query, _opts, context) do
FilterByActorMap.filter_by_map(query, context, :map_connection)
end
end

View File

@@ -0,0 +1,17 @@
defmodule WandererApp.Api.Preparations.FilterPingsByActorMap do
@moduledoc """
Ash preparation that filters pings to only those from the actor's map.
For token-based auth, this ensures the API only returns pings
from the map associated with the token.
"""
use Ash.Resource.Preparation
alias WandererApp.Api.Preparations.FilterByActorMap
@impl true
def prepare(query, _opts, context) do
FilterByActorMap.filter_by_map(query, context, :map_ping)
end
end

View File

@@ -0,0 +1,17 @@
defmodule WandererApp.Api.Preparations.FilterSystemsByActorMap do
@moduledoc """
Ash preparation that filters systems to only those from the actor's map.
For token-based auth, this ensures the API only returns systems
from the map associated with the token.
"""
use Ash.Resource.Preparation
alias WandererApp.Api.Preparations.FilterByActorMap
@impl true
def prepare(query, _opts, context) do
FilterByActorMap.filter_by_map(query, context, :map_system)
end
end

View File

@@ -0,0 +1,62 @@
defmodule WandererApp.Api.Preparations.SecureApiKeyLookup do
@moduledoc """
Preparation that performs secure API key lookup using constant-time comparison.
This preparation:
1. Queries for the map with the given API key using database index
2. Performs constant-time comparison to verify the key matches
3. Returns the map only if the secure comparison passes
The constant-time comparison prevents timing attacks where an attacker
could deduce information about valid API keys by measuring response times.
"""
use Ash.Resource.Preparation
require Ash.Query
@dummy_key "dummy_key_for_timing_consistency_00000000"
def prepare(query, _params, _context) do
api_key = Ash.Query.get_argument(query, :api_key)
if is_nil(api_key) or api_key == "" do
# Return empty result for invalid input
Ash.Query.filter(query, expr(false))
else
# First, do the database lookup using the index
# Then apply constant-time comparison in after_action
query
|> Ash.Query.filter(expr(public_api_key == ^api_key))
|> Ash.Query.after_action(fn _query, results ->
verify_results_with_secure_compare(results, api_key)
end)
end
end
defp verify_results_with_secure_compare(results, provided_key) do
case results do
[map] ->
# Map found - verify with constant-time comparison
stored_key = map.public_api_key || @dummy_key
if Plug.Crypto.secure_compare(stored_key, provided_key) do
{:ok, [map]}
else
# Keys don't match (shouldn't happen if DB returned it, but safety check)
{:ok, []}
end
[] ->
# No map found - still do a comparison to maintain consistent timing
# This prevents timing attacks from distinguishing "not found" from "found but wrong"
_result = Plug.Crypto.secure_compare(@dummy_key, provided_key)
{:ok, []}
_multiple ->
# Multiple results - shouldn't happen with unique constraint
# Do comparison for timing consistency and return error
_result = Plug.Crypto.secure_compare(@dummy_key, provided_key)
{:ok, []}
end
end
end

View File

@@ -49,7 +49,11 @@ defmodule WandererApp.Api.ShipTypeInfo do
:volume
]
defaults [:read, :destroy, :update]
defaults [:read, :destroy]
update :update do
require_atomic? false
end
create :create do
primary? true

View File

@@ -51,10 +51,15 @@ defmodule WandererApp.Api.User do
:hash
]
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
update :update_last_map do
accept([:last_map_id])
require_atomic? false
end
update :update_balance do

View File

@@ -4,7 +4,8 @@ defmodule WandererApp.Api.UserActivity do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource]
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
require Ash.Expr
@@ -30,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)
@@ -55,7 +63,8 @@ defmodule WandererApp.Api.UserActivity do
:entity_type,
:event_type,
:event_data,
:user_id
:user_id,
:character_id
]
read :read do
@@ -70,14 +79,8 @@ defmodule WandererApp.Api.UserActivity do
end
create :new do
accept [:entity_id, :entity_type, :event_type, :event_data]
accept [:entity_id, :entity_type, :event_type, :event_data, :user_id, :character_id]
primary?(true)
argument :user_id, :uuid, allow_nil?: true
argument :character_id, :uuid, allow_nil?: true
change manage_relationship(:user_id, :user, on_lookup: :relate, on_no_match: nil)
change manage_relationship(:character_id, :character, on_lookup: :relate, on_no_match: nil)
end
destroy :archive do
@@ -90,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: [
@@ -108,6 +113,7 @@ defmodule WandererApp.Api.UserActivity do
attribute :event_type, :atom do
default "custom"
public? true
constraints(
one_of: [
@@ -157,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

@@ -28,10 +28,6 @@ defmodule WandererApp.Api.UserTransaction do
create :new do
accept [:journal_ref_id, :user_id, :date, :amount, :corporation_id]
primary?(true)
argument :user_id, :uuid, allow_nil?: false
change manage_relationship(:user_id, :user, on_lookup: :relate, on_no_match: nil)
end
end

View File

@@ -153,13 +153,16 @@ defmodule WandererApp.Application do
:ok
end
defp maybe_start_corp_wallet_tracker(true),
do: [
WandererApp.StartCorpWalletTrackerTask
]
defp maybe_start_corp_wallet_tracker(true) do
# Don't start corp wallet tracker in test environment
if Application.get_env(:wanderer_app, :environment) == :test do
[]
else
[WandererApp.StartCorpWalletTrackerTask]
end
end
defp maybe_start_corp_wallet_tracker(_),
do: []
defp maybe_start_corp_wallet_tracker(_), do: []
defp maybe_start_kills_services do
# Don't start kills services in test environment

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

@@ -93,6 +93,8 @@ defmodule WandererApp.CachedInfo do
end
end
def get_system_static_info(nil), do: {:ok, nil}
def get_system_static_info(solar_system_id) do
{:ok, solar_system_id} = APIUtils.parse_int(solar_system_id)

View File

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

View File

@@ -1,5 +1,18 @@
defmodule WandererApp.Character.TrackerManager.Impl do
@moduledoc false
@moduledoc """
Implementation of the character tracker manager.
This module manages the lifecycle of character trackers and handles:
- Starting/stopping character tracking
- Garbage collection of inactive trackers (5-minute timeout)
- Processing the untrack queue (5-minute interval)
## Logging
This module emits detailed logs for debugging character tracking issues:
- WARNING: Unexpected states or potential issues
- DEBUG: Start/stop tracking events, garbage collection, queue processing
"""
require Logger
defstruct [
@@ -27,6 +40,13 @@ 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"
)
%{
characters: [],
opts: args
@@ -38,6 +58,12 @@ defmodule WandererApp.Character.TrackerManager.Impl do
{:ok, tracked_characters} = WandererApp.Cache.lookup("tracked_characters", [])
WandererApp.Cache.insert("tracked_characters", [])
if length(tracked_characters) > 0 do
Logger.debug(
"[TrackerManager] Restoring #{length(tracked_characters)} tracked characters from cache"
)
end
tracked_characters
|> Enum.each(fn character_id ->
start_tracking(state, character_id)
@@ -53,7 +79,9 @@ defmodule WandererApp.Character.TrackerManager.Impl do
true
)
Logger.debug(fn -> "Add character to track_characters_queue: #{inspect(character_id)}" end)
Logger.debug(fn ->
"[TrackerManager] Queuing character #{character_id} for tracking start"
end)
WandererApp.Cache.insert_or_update(
"track_characters_queue",
@@ -71,13 +99,33 @@ defmodule WandererApp.Character.TrackerManager.Impl do
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
true <- Enum.member?(characters, character_id),
false <- WandererApp.Cache.has_key?("#{character_id}:track_requested") do
Logger.debug(fn -> "Shutting down character tracker: #{inspect(character_id)}" end)
Logger.debug(fn ->
"[TrackerManager] Stopping tracker for character #{character_id} - " <>
"reason: no active maps (garbage collected after #{div(@inactive_character_timeout, 60_000)} minutes)"
end)
WandererApp.Cache.delete("character:#{character_id}:last_active_time")
WandererApp.Character.delete_character_state(character_id)
WandererApp.Character.TrackerPoolDynamicSupervisor.stop_tracking(character_id)
:telemetry.execute([:wanderer_app, :character, :tracker, :stopped], %{count: 1})
:telemetry.execute(
[:wanderer_app, :character, :tracker, :stopped],
%{count: 1, system_time: System.system_time()},
%{character_id: character_id, reason: :garbage_collection}
)
else
{:ok, characters} when is_list(characters) ->
Logger.debug(fn ->
"[TrackerManager] Character #{character_id} not in tracked list, skipping stop"
end)
false ->
Logger.debug(fn ->
"[TrackerManager] Character #{character_id} has pending track request, skipping stop"
end)
_ ->
:ok
end
WandererApp.Cache.insert_or_update(
@@ -101,13 +149,35 @@ defmodule WandererApp.Character.TrackerManager.Impl do
} = track_settings
) do
if track do
Logger.debug(fn ->
"[TrackerManager] Enabling tracking for character #{character_id} on map #{map_id}"
end)
remove_from_untrack_queue(map_id, character_id)
{:ok, character_state} =
WandererApp.Character.Tracker.update_settings(character_id, track_settings)
case WandererApp.Character.Tracker.update_settings(character_id, track_settings) do
{:ok, character_state} ->
WandererApp.Character.update_character_state(character_id, character_state)
WandererApp.Character.update_character_state(character_id, character_state)
{:error, :not_found} ->
# Tracker process not running yet - this is expected during initial tracking setup
# The tracking_start_time cache key was already set by TrackingUtils.track_character
Logger.debug(fn ->
"[TrackerManager] Tracker not yet running for character #{character_id} - " <>
"tracking will be active via cache key"
end)
{:error, reason} ->
Logger.warning(fn ->
"[TrackerManager] Failed to update settings for character #{character_id}: #{inspect(reason)}"
end)
end
else
Logger.debug(fn ->
"[TrackerManager] Queuing character #{character_id} for untracking from map #{map_id} - " <>
"will be processed within #{div(@untrack_characters_interval, 60_000)} minutes"
end)
add_to_untrack_queue(map_id, character_id)
end
@@ -130,8 +200,20 @@ defmodule WandererApp.Character.TrackerManager.Impl do
"character_untrack_queue",
[],
fn untrack_queue ->
untrack_queue
|> Enum.reject(fn {m_id, c_id} -> m_id == map_id and c_id == character_id end)
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)
if length(filtered) < original_length do
Logger.debug(fn ->
"[TrackerManager] Removed character #{character_id} from untrack queue for map #{map_id} - " <>
"character re-enabled tracking"
end)
end
filtered
end
)
end
@@ -170,6 +252,12 @@ defmodule WandererApp.Character.TrackerManager.Impl do
Process.send_after(self(), :check_start_queue, @check_start_queue_interval)
{:ok, track_characters_queue} = WandererApp.Cache.lookup("track_characters_queue", [])
if length(track_characters_queue) > 0 do
Logger.debug(fn ->
"[TrackerManager] Processing start queue: #{length(track_characters_queue)} characters"
end)
end
track_characters_queue
|> Enum.each(fn character_id ->
track_character(character_id, %{})
@@ -186,35 +274,66 @@ defmodule WandererApp.Character.TrackerManager.Impl do
{:ok, characters} = WandererApp.Cache.lookup("tracked_characters", [])
characters
|> Task.async_stream(
fn character_id ->
case WandererApp.Cache.lookup("character:#{character_id}:last_active_time") do
{:ok, nil} ->
:skip
Logger.debug(fn ->
"[TrackerManager] Running garbage collection on #{length(characters)} tracked characters"
end)
{:ok, last_active_time} ->
duration = DateTime.diff(DateTime.utc_now(), last_active_time, :second)
if duration * 1000 > @inactive_character_timeout do
{:stop, character_id}
else
inactive_characters =
characters
|> Task.async_stream(
fn character_id ->
case WandererApp.Cache.lookup("character:#{character_id}:last_active_time") do
{:ok, nil} ->
# Character is still active (no last_active_time set)
:skip
end
end
end,
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task,
timeout: :timer.seconds(60)
)
|> Enum.each(fn result ->
case result do
{:ok, {:stop, character_id}} ->
Process.send_after(self(), {:stop_track, character_id}, 100)
_ ->
:ok
end
{:ok, last_active_time} ->
duration_seconds = DateTime.diff(DateTime.utc_now(), last_active_time, :second)
duration_ms = duration_seconds * 1000
if duration_ms > @inactive_character_timeout do
Logger.debug(fn ->
"[TrackerManager] Character #{character_id} marked for garbage collection - " <>
"inactive for #{div(duration_seconds, 60)} minutes " <>
"(threshold: #{div(@inactive_character_timeout, 60_000)} minutes)"
end)
{:stop, character_id, duration_seconds}
else
:skip
end
end
end,
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task,
timeout: :timer.seconds(60)
)
|> Enum.reduce([], fn result, acc ->
case result do
{:ok, {:stop, character_id, duration}} ->
[{character_id, duration} | acc]
_ ->
acc
end
end)
if length(inactive_characters) > 0 do
Logger.debug(fn ->
"[TrackerManager] Garbage collection found #{length(inactive_characters)} inactive characters to stop"
end)
# Emit telemetry for garbage collection
:telemetry.execute(
[:wanderer_app, :character, :tracker, :garbage_collection],
%{inactive_count: length(inactive_characters), total_tracked: length(characters)},
%{character_ids: Enum.map(inactive_characters, fn {id, _} -> id end)}
)
end
inactive_characters
|> Enum.each(fn {character_id, _duration} ->
Process.send_after(self(), {:stop_track, character_id}, 100)
end)
state
@@ -226,9 +345,22 @@ defmodule WandererApp.Character.TrackerManager.Impl do
) do
Process.send_after(self(), :untrack_characters, @untrack_characters_interval)
WandererApp.Cache.lookup!("character_untrack_queue", [])
untrack_queue = WandererApp.Cache.lookup!("character_untrack_queue", [])
if length(untrack_queue) > 0 do
Logger.debug(fn ->
"[TrackerManager] Processing untrack queue: #{length(untrack_queue)} character-map pairs"
end)
end
untrack_queue
|> Task.async_stream(
fn {map_id, character_id} ->
Logger.debug(fn ->
"[TrackerManager] Untracking character #{character_id} from map #{map_id} - " <>
"reason: character no longer present on map"
end)
remove_from_untrack_queue(map_id, character_id)
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:solar_system_id")
@@ -255,12 +387,36 @@ defmodule WandererApp.Character.TrackerManager.Impl do
WandererApp.Character.update_character_state(character_id, character_state)
WandererApp.Map.Server.Impl.broadcast!(map_id, :untrack_character, character_id)
# Emit telemetry for untrack event
:telemetry.execute(
[:wanderer_app, :character, :tracker, :untracked_from_map],
%{system_time: System.system_time()},
%{character_id: character_id, map_id: map_id, reason: :presence_left}
)
{:ok, character_id, map_id}
end,
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task,
timeout: :timer.seconds(30)
)
|> Enum.each(fn _result -> :ok end)
|> Enum.each(fn result ->
case result do
{:ok, {:ok, character_id, map_id}} ->
Logger.debug(fn ->
"[TrackerManager] Successfully untracked character #{character_id} from map #{map_id}"
end)
{:exit, reason} ->
Logger.warning(fn ->
"[TrackerManager] Untrack task exited with reason: #{inspect(reason)}"
end)
_ ->
:ok
end
end)
state
end
@@ -268,9 +424,17 @@ defmodule WandererApp.Character.TrackerManager.Impl do
def handle_info({:stop_track, character_id}, state) do
if not WandererApp.Cache.has_key?("character:#{character_id}:is_stop_tracking") do
WandererApp.Cache.insert("character:#{character_id}:is_stop_tracking", true)
Logger.debug(fn -> "Stopping character tracker: #{inspect(character_id)}" end)
Logger.debug(fn ->
"[TrackerManager] Executing stop_track for character #{character_id}"
end)
stop_tracking(state, character_id)
WandererApp.Cache.delete("character:#{character_id}:is_stop_tracking")
else
Logger.debug(fn ->
"[TrackerManager] Character #{character_id} already being stopped, skipping duplicate request"
end)
end
state
@@ -279,7 +443,9 @@ defmodule WandererApp.Character.TrackerManager.Impl do
def track_character(character_id, opts) do
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
false <- Enum.member?(characters, character_id) do
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
Logger.debug(fn ->
"[TrackerManager] Starting tracker for character #{character_id}"
end)
WandererApp.Cache.insert_or_update(
"tracked_characters",
@@ -312,7 +478,30 @@ defmodule WandererApp.Character.TrackerManager.Impl do
character_id,
%{opts: opts}
])
# Emit telemetry for tracker start
:telemetry.execute(
[:wanderer_app, :character, :tracker, :started],
%{count: 1, system_time: System.system_time()},
%{character_id: character_id}
)
else
true ->
Logger.debug(fn ->
"[TrackerManager] Character #{character_id} already being tracked"
end)
WandererApp.Cache.insert_or_update(
"track_characters_queue",
[],
fn existing ->
existing
|> Enum.reject(fn c_id -> c_id == character_id end)
end
)
WandererApp.Cache.delete("#{character_id}:track_requested")
_ ->
WandererApp.Cache.insert_or_update(
"track_characters_queue",

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

@@ -53,24 +53,27 @@ defmodule WandererApp.Character.TrackingUtils do
@doc """
Builds tracking data for all characters with access to a map.
Only includes characters that have actual tracking permission.
"""
def build_tracking_data(map_id, current_user_id) do
with {:ok, map} <-
WandererApp.MapRepo.get(map_id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
),
with {:ok, map} <- WandererApp.MapRepo.get(map_id),
{:ok, user_settings} <- WandererApp.MapUserSettingsRepo.get(map_id, current_user_id),
{:ok, %{characters: characters_with_access}} <-
WandererApp.Maps.load_characters(map, current_user_id) do
# Filter to only characters with actual tracking permission
characters_with_tracking_permission =
filter_characters_with_tracking_permission(characters_with_access, map)
# Map characters to tracking data
{:ok, characters_data} =
build_character_tracking_data(characters_with_access)
build_character_tracking_data(characters_with_tracking_permission)
{:ok, main_character} =
get_main_character(user_settings, characters_with_access, characters_with_access)
get_main_character(
user_settings,
characters_with_tracking_permission,
characters_with_tracking_permission
)
following_character_eve_id =
case user_settings do
@@ -112,10 +115,160 @@ defmodule WandererApp.Character.TrackingUtils do
end)}
end
# Filter characters to only include those with actual tracking permission
# This prevents showing characters in the tracking dialog that will fail when toggled
defp filter_characters_with_tracking_permission(characters, %{id: map_id, owner_id: owner_id}) do
# Load ACLs with members properly (same approach as get_map_characters)
acls = load_map_acls_with_members(map_id)
Enum.filter(characters, fn character ->
has_tracking_permission?(character, owner_id, acls)
end)
end
# Load ACLs with members in the correct format for permission checking
defp load_map_acls_with_members(map_id) do
case WandererApp.Api.MapAccessList.read_by_map(%{map_id: map_id},
load: [access_list: [:owner, :members]]
) do
{:ok, map_access_lists} ->
map_access_lists
|> Enum.map(fn mal -> mal.access_list end)
|> Enum.reject(&is_nil/1)
_ ->
[]
end
end
# Check if a character has tracking permission on a map
# Returns true if the character can be tracked, false otherwise
defp has_tracking_permission?(character, owner_id, acls) do
cond do
# Map owner always has tracking permission
character.id == owner_id ->
true
# Character belongs to same user as map owner
# Note: character data from load_characters may not have user_id, so we need to load it
check_same_user_as_owner_by_id(character.id, owner_id) ->
true
# Check ACL-based permissions
true ->
case WandererApp.Permissions.check_characters_access([character], acls) do
[character_permissions] ->
map_permissions = WandererApp.Permissions.get_permissions(character_permissions)
map_permissions.track_character and map_permissions.view_system
_ ->
false
end
end
end
# Check if character belongs to the same user as the map owner (by character IDs)
defp check_same_user_as_owner_by_id(_character_id, nil), do: false
defp check_same_user_as_owner_by_id(character_id, owner_id) do
with {:ok, character} <- WandererApp.Character.get_character(character_id),
{:ok, owner_character} <- WandererApp.Character.get_character(owner_id) do
character.user_id != nil and character.user_id == owner_character.user_id
else
_ -> false
end
end
# Private implementation of update character tracking
defp do_update_character_tracking(character, map_id, track, caller_pid) do
WandererApp.MapCharacterSettingsRepo.get(map_id, character.id)
|> case do
# First check current tracking state to avoid unnecessary permission checks
current_settings = WandererApp.MapCharacterSettingsRepo.get(map_id, character.id)
case {track, current_settings} do
# Already tracked and wants to stay tracked - no permission check needed
{true, {:ok, %{tracked: true} = settings}} ->
do_update_character_tracking_impl(character, map_id, track, caller_pid, {:ok, settings})
# Wants to enable tracking - check permissions first
{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
)
{:error, reason} ->
Logger.warning(
"[CharacterTracking] Character #{character.id} cannot be tracked on map #{map_id}: #{reason}"
)
{:error, reason}
end
# Untracking is always allowed
{false, settings_result} ->
do_update_character_tracking_impl(character, map_id, track, caller_pid, settings_result)
end
end
# Check if a character has permission to be tracked on a map
defp check_character_tracking_permission(character, map_id) do
with {:ok, %{acls: acls, owner_id: owner_id}} <-
WandererApp.MapRepo.get(map_id,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
) do
# Check if character is the map owner
if character.id == owner_id do
{:ok, :allowed}
else
# Check if character belongs to same user as owner (Option 3 check)
case check_same_user_as_owner(character, owner_id) do
true ->
{:ok, :allowed}
false ->
# Check ACL-based permissions
[character_permissions] =
WandererApp.Permissions.check_characters_access([character], acls)
map_permissions = WandererApp.Permissions.get_permissions(character_permissions)
if map_permissions.track_character and map_permissions.view_system do
{:ok, :allowed}
else
{:error,
"Character does not have tracking permission on this map. Please add the character to a map access list or ensure you are the map owner."}
end
end
end
else
{:error, _} ->
{:error, "Failed to verify map permissions"}
end
end
# Check if character belongs to the same user as the map owner
defp check_same_user_as_owner(_character, nil), do: false
defp check_same_user_as_owner(character, owner_id) do
case WandererApp.Character.get_character(owner_id) do
{:ok, owner_character} ->
character.user_id != nil and character.user_id == owner_character.user_id
_ ->
false
end
end
defp do_update_character_tracking_impl(character, map_id, track, caller_pid, settings_result) do
case settings_result do
# Untracking flow
{:ok, %{tracked: true} = existing_settings} ->
if not track do
@@ -132,6 +285,9 @@ defmodule WandererApp.Character.TrackingUtils do
{:ok, %{tracked: false} = existing_settings} ->
if track do
{:ok, updated_settings} = WandererApp.MapCharacterSettingsRepo.track(existing_settings)
# Ensure character is in map state (fixes race condition where character
# might not be synced yet from presence updates)
:ok = WandererApp.Map.add_character(map_id, character)
:ok = track([character], map_id, true, caller_pid)
{:ok, updated_settings}
else
@@ -148,6 +304,9 @@ defmodule WandererApp.Character.TrackingUtils do
tracked: true
})
# Add character to map state immediately (fixes race condition where
# character wouldn't appear on map until next update_presence cycle)
:ok = WandererApp.Map.add_character(map_id, character)
:ok = track([character], map_id, true, caller_pid)
{:ok, settings}
else
@@ -210,6 +369,31 @@ defmodule WandererApp.Character.TrackingUtils do
if is_track_allowed do
:ok = WandererApp.Character.TrackerManager.start_tracking(character_id)
# Immediately set tracking_start_time cache key to enable map tracking
# This ensures the character is tracked for updates even before the
# Tracker process is fully started (avoids race condition)
tracking_start_key = "character:#{character_id}:map:#{map_id}:tracking_start_time"
case WandererApp.Cache.lookup(tracking_start_key) do
{:ok, nil} ->
WandererApp.Cache.put(tracking_start_key, DateTime.utc_now())
# Clear stale location caches for fresh tracking
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:solar_system_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:station_id")
WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:structure_id")
_ ->
# Already tracking, no need to update
:ok
end
# Also call update_track_settings to update character state when tracker is ready
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{
map_id: map_id,
track: true
})
end
:ok

View File

@@ -17,7 +17,6 @@ defmodule WandererApp.Env do
def invites(), do: get_key(:invites, false)
def map_subscriptions_enabled?(), do: get_key(:map_subscriptions_enabled, false)
def websocket_events_enabled?(), do: get_key(:websocket_events_enabled, false)
def public_api_disabled?(), do: get_key(:public_api_disabled, false)
@decorate cacheable(

View File

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

View File

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

View File

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

View File

@@ -155,26 +155,23 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do
# 1. Store in ETS for backfill
store_event(event, state.ets_table)
# 2. Convert event to JSON for delivery methods
event_json = Event.to_json(event)
Logger.debug(fn ->
"MapEventRelay converted event to JSON: #{inspect(String.slice(inspect(event_json), 0, 200))}..."
end)
# 3. Send to webhook subscriptions via WebhookDispatcher
WebhookDispatcher.dispatch_event(event.map_id, event)
# 4. Broadcast to SSE clients
Logger.debug(fn -> "MapEventRelay broadcasting to SSE clients for map #{event.map_id}" end)
WandererApp.ExternalEvents.SseStreamManager.broadcast_event(event.map_id, event_json)
case WandererApp.ExternalEvents.SseAccessControl.sse_allowed?(event.map_id) do
:ok ->
WandererApp.ExternalEvents.SseStreamManager.broadcast_event(event.map_id, event_json)
# Emit delivered telemetry
:telemetry.execute(
[:wanderer_app, :external_events, :relay, :delivered],
%{count: 1},
%{map_id: event.map_id, event_type: event.type}
)
:telemetry.execute(
[:wanderer_app, :external_events, :relay, :delivered],
%{count: 1},
%{map_id: event.map_id, event_type: event.type}
)
{:error, _reason} ->
:ok
end
%{state | event_count: state.event_count + 1}
end

View File

@@ -0,0 +1,71 @@
defmodule WandererApp.ExternalEvents.SseAccessControl do
@moduledoc """
Handles SSE access control checks including subscription validation.
Note: Community Edition mode is automatically handled by the
WandererApp.Map.is_subscription_active?/1 function, which returns
{:ok, true} when subscriptions are disabled globally.
"""
@doc """
Checks if SSE is allowed for a given map.
Returns:
- :ok if SSE is allowed
- {:error, reason} if SSE is not allowed
Checks in order:
1. Global SSE enabled (config)
2. Map exists
3. Map SSE enabled (per-map setting)
4. Subscription active (CE mode handled internally)
"""
def sse_allowed?(map_id) do
with :ok <- check_sse_globally_enabled(),
{:ok, map} <- fetch_map(map_id),
:ok <- check_map_sse_enabled(map),
:ok <- check_subscription_or_ce(map_id) do
:ok
end
end
defp check_sse_globally_enabled do
if WandererApp.Env.sse_enabled?() do
:ok
else
{:error, :sse_globally_disabled}
end
end
# Fetches the map by ID.
# Returns {:ok, map} or {:error, :map_not_found}
defp fetch_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, _map} = result -> result
_ -> {:error, :map_not_found}
end
end
defp check_map_sse_enabled(map) do
if map.sse_enabled do
:ok
else
{:error, :sse_disabled_for_map}
end
end
# Checks if map has active subscription or if running Community Edition.
#
# Returns :ok if:
# - Community Edition (handled internally by is_subscription_active?/1), OR
# - Map has active subscription
#
# Returns {:error, :subscription_required} if subscription check fails.
defp check_subscription_or_ce(map_id) do
case WandererApp.Map.is_subscription_active?(map_id) do
{:ok, true} -> :ok
{:ok, false} -> {:error, :subscription_required}
{:error, _reason} = error -> error
end
end
end

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

@@ -403,10 +403,24 @@ defmodule WandererApp.Kills.MessageHandler do
defp extract_field(_data, _field_names), do: nil
# Specific field extractors using the generic function
# Generic nested field extraction - tries flat keys first, then nested object
@spec extract_nested_field(map(), list(String.t()), String.t(), String.t()) :: String.t() | nil
defp extract_nested_field(data, flat_keys, nested_key, field) when is_map(data) do
case extract_field(data, flat_keys) do
nil ->
case data[nested_key] do
%{^field => value} when is_binary(value) and value != "" -> value
_ -> nil
end
value ->
value
end
end
# Specific field extractors using the generic functions
@spec get_character_name(map() | any()) :: String.t() | nil
defp get_character_name(data) when is_map(data) do
# Try multiple possible field names
field_names = ["attacker_name", "victim_name", "character_name", "name"]
extract_field(data, field_names) ||
@@ -419,30 +433,26 @@ defmodule WandererApp.Kills.MessageHandler do
defp get_character_name(_), do: nil
@spec get_corp_ticker(map() | any()) :: String.t() | nil
defp get_corp_ticker(data) when is_map(data) do
extract_field(data, ["corporation_ticker", "corp_ticker"])
end
defp get_corp_ticker(data) when is_map(data),
do: extract_nested_field(data, ["corporation_ticker", "corp_ticker"], "corporation", "ticker")
defp get_corp_ticker(_), do: nil
@spec get_corp_name(map() | any()) :: String.t() | nil
defp get_corp_name(data) when is_map(data) do
extract_field(data, ["corporation_name", "corp_name"])
end
defp get_corp_name(data) when is_map(data),
do: extract_nested_field(data, ["corporation_name", "corp_name"], "corporation", "name")
defp get_corp_name(_), do: nil
@spec get_alliance_ticker(map() | any()) :: String.t() | nil
defp get_alliance_ticker(data) when is_map(data) do
extract_field(data, ["alliance_ticker"])
end
defp get_alliance_ticker(data) when is_map(data),
do: extract_nested_field(data, ["alliance_ticker"], "alliance", "ticker")
defp get_alliance_ticker(_), do: nil
@spec get_alliance_name(map() | any()) :: String.t() | nil
defp get_alliance_name(data) when is_map(data) do
extract_field(data, ["alliance_name"])
end
defp get_alliance_name(data) when is_map(data),
do: extract_nested_field(data, ["alliance_name"], "alliance", "name")
defp get_alliance_name(_), do: nil

View File

@@ -177,7 +177,7 @@ defmodule WandererApp.Map do
end
def list_hubs(map_id, hubs) do
{:ok, map} = map_id |> get_map()
{:ok, _map} = map_id |> get_map()
{:ok, hubs}
end
@@ -205,7 +205,7 @@ defmodule WandererApp.Map do
characters_ids =
characters
|> Enum.map(fn %{id: char_id} -> char_id end)
|> Enum.map(fn %{character_id: char_id} -> char_id end)
# Filter out characters that already exist
new_character_ids =
@@ -315,7 +315,7 @@ defmodule WandererApp.Map do
end
end
def update_subscription_settings!(%{map_id: map_id} = map, %{
def update_subscription_settings!(%{map_id: map_id} = _map, %{
characters_limit: characters_limit,
hubs_limit: hubs_limit
}) do
@@ -326,7 +326,7 @@ defmodule WandererApp.Map do
|> get_map!()
end
def update_options!(%{map_id: map_id} = map, options) do
def update_options!(%{map_id: map_id} = _map, options) do
map_id
|> update_map(%{options: options})

View File

@@ -348,9 +348,9 @@ defmodule WandererApp.Map.CacheRTree do
[{x1_min, x1_max}, {y1_min, y1_max}] = box1
[{x2_min, x2_max}, {y2_min, y2_max}] = box2
# Boxes intersect if they overlap on both axes
x_overlap = x1_min <= x2_max and x2_min <= x1_max
y_overlap = y1_min <= y2_max and y2_min <= y1_max
# Boxes intersect if they overlap on both axes (strict intersection - not just touching)
x_overlap = x1_min < x2_max and x2_min < x1_max
y_overlap = y1_min < y2_max and y2_min < y1_max
x_overlap and y_overlap
end

View File

@@ -9,6 +9,8 @@ defmodule WandererApp.Map.Manager do
alias WandererApp.Map.Server
@environment Application.compile_env(:wanderer_app, :environment)
@maps_start_chunk_size 20
@maps_start_interval 500
@maps_queue :maps_queue
@@ -19,7 +21,7 @@ defmodule WandererApp.Map.Manager do
# Test-aware async task runner
defp safe_async_task(fun) do
if Mix.env() == :test do
if @environment == :test do
# In tests, run synchronously to avoid database ownership issues
try do
fun.()
@@ -113,11 +115,20 @@ defmodule WandererApp.Map.Manager do
Enum.each(pings, fn %{id: ping_id, map_id: map_id, type: type} = ping ->
{:ok, %{system: system}} = ping |> Ash.load([:system])
Server.Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: system.solar_system_id,
type: type
})
# Handle case where parent system was already deleted
case system do
nil ->
Logger.warning(
"[cleanup_expired_pings] ping #{ping_id} destroyed (parent system already deleted)"
)
%{solar_system_id: solar_system_id} ->
Server.Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: solar_system_id,
type: type
})
end
Ash.destroy!(ping)
end)
@@ -139,7 +150,7 @@ defmodule WandererApp.Map.Manager do
WandererApp.Queue.clear(@maps_queue)
if Mix.env() == :test do
if @environment == :test do
# In tests, run synchronously to avoid database ownership issues
Logger.debug(fn -> "Starting maps synchronously in test mode" end)

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

@@ -18,10 +18,22 @@ defmodule WandererApp.Map.MapPool do
@map_pool_limit 10
@garbage_collection_interval :timer.hours(4)
@systems_cleanup_timeout :timer.minutes(30)
@characters_cleanup_timeout :timer.minutes(5)
@connections_cleanup_timeout :timer.minutes(5)
@backup_state_timeout :timer.minutes(1)
# Use very long timeouts in test environment to prevent background tasks from running during tests
# This avoids database connection ownership errors when tests finish before async tasks complete
@environment Application.compile_env(:wanderer_app, :environment)
@systems_cleanup_timeout if @environment == :test,
do: :timer.hours(24),
else: :timer.minutes(30)
@characters_cleanup_timeout if @environment == :test,
do: :timer.hours(24),
else: :timer.minutes(5)
@connections_cleanup_timeout if @environment == :test,
do: :timer.hours(24),
else: :timer.minutes(5)
@backup_state_timeout if @environment == :test,
do: :timer.hours(24),
else: :timer.minutes(1)
def new(), do: __struct__()
def new(args), do: __struct__(args)
@@ -187,7 +199,7 @@ defmodule WandererApp.Map.MapPool do
# Schedule periodic tasks
Process.send_after(self(), :backup_state, @backup_state_timeout)
Process.send_after(self(), :cleanup_systems, 15_000)
Process.send_after(self(), :cleanup_systems, @systems_cleanup_timeout)
Process.send_after(self(), :cleanup_characters, @characters_cleanup_timeout)
Process.send_after(self(), :cleanup_connections, @connections_cleanup_timeout)
Process.send_after(self(), :garbage_collect, @garbage_collection_interval)
@@ -317,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
@@ -332,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
@@ -351,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]}
@@ -433,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
@@ -452,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)}
@@ -548,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

@@ -106,6 +106,9 @@ defmodule WandererApp.Map.PositionCalculator do
defp get_start_index(n, "top_to_bottom"), do: div(n, 2) + n - 1
# Default to left_to_right when layout is nil
defp get_start_index(n, nil), do: div(n, 2)
defp adjusted_coordinates(n, start_x, start_y, opts) when n > 1 do
sorted_coords = sorted_edge_coordinates(n, opts)

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

@@ -56,6 +56,8 @@ defmodule WandererApp.Map.Server do
defdelegate update_system_temporary_name(map_id, update), to: Impl
defdelegate update_system_custom_name(map_id, update), to: Impl
defdelegate update_system_locked(map_id, update), to: Impl
defdelegate update_system_labels(map_id, update), to: Impl

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}
@@ -72,7 +71,7 @@ defmodule WandererApp.Map.Operations.Duplication do
Logger.debug("Copying systems for map #{source_map.id}")
# Get all systems from source map using Ash
case MapSystem |> Ash.Query.filter(map_id == ^source_map.id) |> Ash.read() do
case MapSystem.read_all_by_map(%{map_id: source_map.id}) do
{:ok, source_systems} ->
system_mapping = %{}
@@ -126,7 +125,7 @@ defmodule WandererApp.Map.Operations.Duplication do
defp copy_connections(source_map, new_map, system_mapping) do
Logger.debug("Copying connections for map #{source_map.id}")
case MapConnection |> Ash.Query.filter(map_id == ^source_map.id) |> Ash.read() do
case MapConnection.read_by_map(%{map_id: source_map.id}) do
{:ok, source_connections} ->
Enum.reduce_while(source_connections, {:ok, []}, fn source_connection,
{:ok, acc_connections} ->
@@ -222,7 +221,7 @@ defmodule WandererApp.Map.Operations.Duplication do
source_system_ids = Map.keys(system_mapping)
Enum.flat_map(source_system_ids, fn system_id ->
case MapSystemSignature |> Ash.Query.filter(system_id == ^system_id) |> Ash.read() do
case MapSystemSignature.by_system_id_all(%{system_id: system_id}) do
{:ok, signatures} -> signatures
{:error, _} -> []
end
@@ -355,7 +354,7 @@ defmodule WandererApp.Map.Operations.Duplication do
defp maybe_copy_user_settings(source_map, new_map, true) do
Logger.debug("Copying user settings for map #{source_map.id}")
case MapCharacterSettings |> Ash.Query.filter(map_id == ^source_map.id) |> Ash.read() do
case MapCharacterSettings.read_by_map(%{map_id: source_map.id}) do
{:ok, source_settings} ->
Enum.reduce_while(source_settings, {:ok, []}, fn source_setting, {:ok, acc_settings} ->
case copy_single_character_setting(source_setting, new_map.id) do

View File

@@ -8,35 +8,38 @@ defmodule WandererApp.Map.Operations.Signatures do
alias WandererApp.Api.{Character, MapSystem, MapSystemSignature}
alias WandererApp.Map.Server
# Private helper to validate character_eve_id from params and return internal character ID
# If character_eve_id is provided in params, validates it exists and returns the internal UUID
# If not provided, falls back to the owner's character ID (which is already the internal UUID)
@spec validate_character_eve_id(map() | nil, String.t()) ::
{:ok, String.t()} | {:error, :invalid_character}
{:ok, String.t()} | {:error, :invalid_character} | {:error, :unexpected_error}
defp validate_character_eve_id(params, fallback_char_id) when is_map(params) do
case Map.get(params, "character_eve_id") do
nil ->
# No character_eve_id provided, use fallback (owner's internal character UUID)
{:ok, fallback_char_id}
provided_char_eve_id when is_binary(provided_char_eve_id) ->
# Validate the provided character_eve_id exists and get internal UUID
case Character.by_eve_id(provided_char_eve_id) do
{:ok, character} ->
# Return the internal character UUID, not the eve_id
{:ok, character.id}
_ ->
{:error, %Ash.Error.Query.NotFound{}} ->
{:error, :invalid_character}
{:error, %Ash.Error.Invalid{}} ->
# Invalid format (e.g., non-numeric string for an integer field)
{:error, :invalid_character}
{:error, reason} ->
Logger.error(
"[validate_character_eve_id] Unexpected error looking up character: #{inspect(reason)}"
)
{:error, :unexpected_error}
end
_ ->
# Invalid format
{:error, :invalid_character}
end
end
# Handle nil or non-map params by falling back to owner's character
defp validate_character_eve_id(_params, fallback_char_id) do
{:ok, fallback_char_id}
end
@@ -74,12 +77,8 @@ defmodule WandererApp.Map.Operations.Signatures do
%{"solar_system_id" => solar_system_id} = params
)
when is_integer(solar_system_id) do
# Validate character first, then convert solar_system_id to system_id
# validated_char_uuid is the internal character UUID for Server.update_signatures
with {:ok, validated_char_uuid} <- validate_character_eve_id(params, char_id),
{:ok, system} <- MapSystem.by_map_id_and_solar_system_id(map_id, solar_system_id) do
# Keep character_eve_id in attrs if provided by user (parse_signatures will use it)
# If not provided, parse_signatures will use the character_eve_id from validated_char_uuid lookup
attrs =
params
|> Map.put("system_id", system.id)
@@ -90,7 +89,6 @@ defmodule WandererApp.Map.Operations.Signatures do
updated_signatures: [],
removed_signatures: [],
solar_system_id: solar_system_id,
# Pass internal UUID here
character_id: validated_char_uuid,
user_id: user_id,
delete_connection_with_sigs: false
@@ -127,6 +125,10 @@ defmodule WandererApp.Map.Operations.Signatures do
Logger.error("[create_signature] Invalid character_eve_id provided")
{:error, :invalid_character}
{:error, :unexpected_error} ->
Logger.error("[create_signature] Unexpected error during character validation")
{:error, :unexpected_error}
_ ->
Logger.error(
"[create_signature] System not found for solar_system_id: #{solar_system_id}"
@@ -152,8 +154,6 @@ defmodule WandererApp.Map.Operations.Signatures do
sig_id,
params
) do
# Validate character first, then look up signature and system
# validated_char_uuid is the internal character UUID
with {:ok, validated_char_uuid} <- validate_character_eve_id(params, char_id),
{:ok, sig} <- MapSystemSignature.by_id(sig_id),
{:ok, system} <- MapSystem.by_id(sig.system_id) do
@@ -177,7 +177,6 @@ defmodule WandererApp.Map.Operations.Signatures do
updated_signatures: [attrs],
removed_signatures: [],
solar_system_id: system.solar_system_id,
# Pass internal UUID here
character_id: validated_char_uuid,
user_id: user_id,
delete_connection_with_sigs: false
@@ -200,9 +199,13 @@ defmodule WandererApp.Map.Operations.Signatures do
Logger.error("[update_signature] Invalid character_eve_id provided")
{:error, :invalid_character}
err ->
Logger.error("[update_signature] Unexpected error: #{inspect(err)}")
{:error, :unexpected_error} ->
Logger.error("[update_signature] Unexpected error during character validation")
{:error, :unexpected_error}
err ->
Logger.error("[update_signature] Signature or system not found: #{inspect(err)}")
{:error, :not_found}
end
end

View File

@@ -35,21 +35,22 @@ defmodule WandererApp.Map.Operations.Systems do
# Private helper for batch upsert
defp create_system_batch(%{map_id: map_id, user_id: user_id, char_id: char_id}, params) do
{:ok, solar_system_id} = fetch_system_id(params)
update_existing = fetch_update_existing(params, false)
with {:ok, solar_system_id} <- fetch_system_id(params) do
update_existing = fetch_update_existing(params, false)
map_id
|> WandererApp.Map.check_location(%{solar_system_id: solar_system_id})
|> case do
{:ok, _location} ->
do_create_system(map_id, user_id, char_id, params)
map_id
|> WandererApp.Map.check_location(%{solar_system_id: solar_system_id})
|> case do
{:ok, _location} ->
do_create_system(map_id, user_id, char_id, params)
{:error, :already_exists} ->
if update_existing do
do_update_system(map_id, user_id, char_id, solar_system_id, params)
else
:ok
end
{:error, :already_exists} ->
if update_existing do
do_update_system(map_id, user_id, char_id, solar_system_id, params)
else
:ok
end
end
end
end
@@ -106,8 +107,8 @@ defmodule WandererApp.Map.Operations.Systems do
Logger.warning("[update_system] Expected error: #{inspect(reason)}")
{:error, :expected_error}
_ ->
Logger.error("[update_system] Unexpected error")
error ->
Logger.error("[update_system] Unexpected error: #{inspect(error)}")
{:error, :unexpected_error}
end
end
@@ -185,6 +186,8 @@ defmodule WandererApp.Map.Operations.Systems do
defp parse_int(val, _field) when is_integer(val), do: {:ok, val}
defp parse_int(val, _field) when is_float(val), do: {:ok, trunc(val)}
defp parse_int(val, field) when is_binary(val) do
case Integer.parse(val) do
{i, _} -> {:ok, i}
@@ -268,12 +271,9 @@ defmodule WandererApp.Map.Operations.Systems do
})
"custom_name" ->
{:ok, solar_system_info} =
WandererApp.CachedInfo.get_system_static_info(system_id)
Server.update_system_name(map_id, %{
Server.update_system_custom_name(map_id, %{
solar_system_id: system_id,
name: val || solar_system_info.solar_system_name
custom_name: val
})
"temporary_name" ->

View File

@@ -68,6 +68,9 @@ defmodule WandererApp.Map.Server.AclsImpl do
WandererApp.Map.update_map_state(map_id, %{
map: Map.merge(old_map, map_update)
})
# Broadcast to map channel so all viewers can refresh their available characters
WandererApp.Map.Server.Impl.broadcast!(map_id, :acl_members_changed, %{})
end
def handle_acl_updated(map_id, acl_id) do
@@ -87,6 +90,10 @@ defmodule WandererApp.Map.Server.AclsImpl do
acl_id
|> update_acl()
|> broadcast_acl_updates(map_id)
# Broadcast to map channel so all viewers can refresh their available characters
# This fixes the issue where users don't see newly added ACL members as available for tracking
WandererApp.Map.Server.Impl.broadcast!(map_id, :acl_members_changed, %{acl_id: acl_id})
end
end
@@ -108,6 +115,9 @@ defmodule WandererApp.Map.Server.AclsImpl do
|> Map.get(:characters, [])
WandererApp.Cache.insert("map_#{map_id}:invalidate_character_ids", character_ids)
# Broadcast to map channel so all viewers can refresh their available characters
WandererApp.Map.Server.Impl.broadcast!(map_id, :acl_members_changed, %{})
end
def track_acls([]), do: :ok

View File

@@ -1,5 +1,19 @@
defmodule WandererApp.Map.Server.CharactersImpl do
@moduledoc false
@moduledoc """
Handles character-related operations for map servers.
This module manages:
- Character tracking on maps
- Permission-based character cleanup
- Character presence updates
## Logging
This module emits detailed logs for debugging character tracking issues:
- INFO: Character track/untrack events, permission cleanup results
- WARNING: Permission failures, unexpected states
- DEBUG: Detailed permission check results
"""
require Logger
@@ -15,6 +29,11 @@ defmodule WandererApp.Map.Server.CharactersImpl do
if Enum.empty?(invalidate_character_ids) do
:ok
else
Logger.debug(fn ->
"[CharactersImpl] Running permission cleanup for map #{map_id} - " <>
"checking #{length(invalidate_character_ids)} characters"
end)
{:ok, %{acls: acls}} =
WandererApp.MapRepo.get(map_id,
acls: [
@@ -30,6 +49,11 @@ defmodule WandererApp.Map.Server.CharactersImpl do
def track_characters(_map_id, []), do: :ok
def track_characters(map_id, [character_id | rest]) do
Logger.debug(fn ->
"[CharactersImpl] Starting tracking for character #{character_id} on map #{map_id} - " <>
"reason: character joined presence"
end)
track_character(map_id, character_id)
track_characters(map_id, rest)
end
@@ -41,6 +65,12 @@ defmodule WandererApp.Map.Server.CharactersImpl do
|> WandererApp.Map.get_map!()
|> Map.get(:characters, [])
if length(character_ids) > 0 do
Logger.debug(fn ->
"[CharactersImpl] Scheduling permission check for #{length(character_ids)} characters on map #{map_id}"
end)
end
WandererApp.Cache.insert("map_#{map_id}:invalidate_character_ids", character_ids)
:ok
@@ -48,6 +78,13 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
def untrack_characters(map_id, character_ids) do
if length(character_ids) > 0 do
Logger.debug(fn ->
"[CharactersImpl] Untracking #{length(character_ids)} characters from map #{map_id} - " <>
"reason: characters no longer in presence_character_ids (grace period expired or user disconnected)"
end)
end
character_ids
|> Enum.each(fn character_id ->
character_map_active = is_character_map_active?(map_id, character_id)
@@ -58,13 +95,32 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
defp untrack_character(true, map_id, character_id) do
Logger.info(fn ->
"[CharactersImpl] Untracking character #{character_id} from map #{map_id} - " <>
"character was actively tracking this map"
end)
# Emit telemetry for tracking
:telemetry.execute(
[:wanderer_app, :character, :tracking, :stopped],
%{system_time: System.system_time()},
%{character_id: character_id, map_id: map_id, reason: :presence_expired}
)
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{
map_id: map_id,
track: false
})
end
defp untrack_character(_is_character_map_active, _map_id, _character_id), do: :ok
defp untrack_character(false, map_id, character_id) do
Logger.debug(fn ->
"[CharactersImpl] Skipping untrack for character #{character_id} on map #{map_id} - " <>
"character was not actively tracking this map"
end)
:ok
end
defp is_character_map_active?(map_id, character_id) do
case WandererApp.Character.get_character_state(character_id) do
@@ -79,59 +135,134 @@ defmodule WandererApp.Map.Server.CharactersImpl do
defp process_invalidate_characters(invalidate_character_ids, map_id, acls) do
{:ok, %{map: %{owner_id: owner_id}}} = WandererApp.Map.get_map_state(map_id)
invalidate_character_ids
|> Task.async_stream(
fn character_id ->
character_id
|> WandererApp.Character.get_character()
|> case do
{:ok, %{user_id: nil}} ->
{:remove_character, character_id}
# Option 3: Get owner's user_id to allow all characters from the same user
owner_user_id = get_owner_user_id(owner_id)
{:ok, character} ->
[character_permissions] =
WandererApp.Permissions.check_characters_access([character], acls)
map_permissions =
WandererApp.Permissions.get_map_permissions(
character_permissions,
owner_id,
[character_id]
)
case map_permissions do
%{view_system: false} ->
{:remove_character, character_id}
%{track_character: false} ->
{:remove_character, character_id}
_ ->
:ok
end
_ ->
:ok
end
end,
timeout: :timer.seconds(60),
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, {:remove_character, character_id}}, acc ->
[character_id | acc]
{:ok, _result}, acc ->
acc
{:error, reason}, acc ->
Logger.error("Error in cleanup_characters: #{inspect(reason)}")
acc
Logger.debug(fn ->
"[CharacterCleanup] Map #{map_id} - validating permissions for #{length(invalidate_character_ids)} characters"
end)
|> case do
[] -> :ok
character_ids_to_remove -> remove_and_untrack_characters(map_id, character_ids_to_remove)
results =
invalidate_character_ids
|> Task.async_stream(
fn character_id ->
character_id
|> WandererApp.Character.get_character()
|> case do
{:ok, %{user_id: nil}} ->
{:remove_character, character_id, :no_user_id}
{:ok, character} ->
# Option 3: Check if character belongs to the same user as owner
is_same_user_as_owner =
owner_user_id != nil and character.user_id == owner_user_id
if is_same_user_as_owner do
# All characters from the map owner's account have full access
:ok
else
[character_permissions] =
WandererApp.Permissions.check_characters_access([character], acls)
map_permissions =
WandererApp.Permissions.get_map_permissions(
character_permissions,
owner_id,
[character_id]
)
case map_permissions do
%{view_system: false} ->
{:remove_character, character_id, :no_view_permission}
%{track_character: false} ->
{:remove_character, character_id, :no_track_permission}
_ ->
:ok
end
end
_ ->
:ok
end
end,
timeout: :timer.seconds(60),
max_concurrency: System.schedulers_online() * 4,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, {:remove_character, character_id, reason}}, acc ->
[{character_id, reason} | acc]
{:ok, _result}, acc ->
acc
{:error, reason}, acc ->
Logger.error(
"[CharacterCleanup] Error checking character permissions: #{inspect(reason)}"
)
acc
end)
case results do
[] ->
Logger.debug(fn ->
"[CharacterCleanup] Map #{map_id} - all #{length(invalidate_character_ids)} characters passed permission check"
end)
:ok
characters_to_remove ->
# Group by reason for better logging
by_reason = Enum.group_by(characters_to_remove, fn {_id, reason} -> reason end)
Enum.each(by_reason, fn {reason, chars} ->
char_ids = Enum.map(chars, fn {id, _} -> id end)
reason_str = permission_removal_reason_to_string(reason)
Logger.debug(fn ->
"[CharacterCleanup] Map #{map_id} - removing #{length(char_ids)} characters: #{reason_str} - " <>
"character_ids: #{inspect(char_ids)}"
end)
# Emit telemetry for each removal reason
:telemetry.execute(
[:wanderer_app, :character, :tracking, :permission_revoked],
%{count: length(char_ids), system_time: System.system_time()},
%{map_id: map_id, character_ids: char_ids, reason: reason}
)
end)
character_ids_to_remove = Enum.map(characters_to_remove, fn {id, _} -> id end)
Logger.debug(fn ->
"[CharacterCleanup] Map #{map_id} - total #{length(character_ids_to_remove)} characters " <>
"will be removed due to permission issues (NO GRACE PERIOD)"
end)
remove_and_untrack_characters(map_id, character_ids_to_remove)
end
end
defp permission_removal_reason_to_string(:no_user_id),
do: "no user_id associated with character"
defp permission_removal_reason_to_string(:no_view_permission), do: "lost view_system permission"
defp permission_removal_reason_to_string(:no_track_permission),
do: "lost track_character permission"
defp permission_removal_reason_to_string(reason), do: "#{inspect(reason)}"
# Helper to get the owner's user_id for Option 3
defp get_owner_user_id(nil), do: nil
defp get_owner_user_id(owner_id) do
case WandererApp.Character.get_character(owner_id) do
{:ok, %{user_id: user_id}} -> user_id
_ -> nil
end
end
@@ -161,10 +292,18 @@ defmodule WandererApp.Map.Server.CharactersImpl do
end
defp remove_and_untrack_characters(map_id, character_ids) do
Logger.debug(fn ->
"Map #{map_id} - remove and untrack characters #{inspect(character_ids)}"
# Option 4: Enhanced logging for character removal
Logger.info(fn ->
"[CharacterCleanup] Map #{map_id} - starting removal of #{length(character_ids)} characters: #{inspect(character_ids)}"
end)
# Emit telemetry for monitoring
:telemetry.execute(
[:wanderer_app, :map, :characters_cleanup, :removal_started],
%{character_count: length(character_ids), system_time: System.system_time()},
%{map_id: map_id, character_ids: character_ids}
)
map_id
|> untrack_characters(character_ids)
@@ -174,10 +313,21 @@ defmodule WandererApp.Map.Server.CharactersImpl do
{:ok, settings} ->
settings
|> Enum.each(fn s ->
Logger.info(fn ->
"[CharacterCleanup] Map #{map_id} - destroying settings and removing character #{s.character_id}"
end)
WandererApp.MapCharacterSettingsRepo.destroy!(s)
remove_character(map_id, s.character_id)
end)
# Emit telemetry for successful removal
:telemetry.execute(
[:wanderer_app, :map, :characters_cleanup, :removal_complete],
%{removed_count: length(settings), system_time: System.system_time()},
%{map_id: map_id}
)
_ ->
:ok
end
@@ -664,14 +814,16 @@ defmodule WandererApp.Map.Server.CharactersImpl do
do: :ok
defp update_location(
%{map: %{scope: scope}, map_id: map_id, map_opts: map_opts} =
%{map: map, map_id: map_id, map_opts: map_opts} =
_state,
character_id,
location,
old_location
) do
scopes = get_effective_scopes(map)
ConnectionsImpl.is_connection_valid(
scope,
scopes,
old_location.solar_system_id,
location.solar_system_id
)
@@ -729,6 +881,21 @@ 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)
# Get effective scopes from map, with fallback to legacy scope
defp get_effective_scopes(%{scopes: scopes}) when is_list(scopes) and scopes != [], do: scopes
defp get_effective_scopes(%{scope: scope}) when is_atom(scope),
do: legacy_scope_to_scopes(scope)
defp get_effective_scopes(_), do: [:wormholes]
# Legacy scope to new scopes array conversion
defp legacy_scope_to_scopes(:wormholes), do: [:wormholes]
defp legacy_scope_to_scopes(:stargates), do: [:hi, :low, :null, :pochven]
defp legacy_scope_to_scopes(:all), do: [:wormholes, :hi, :low, :null, :pochven]
defp legacy_scope_to_scopes(:none), do: []
defp legacy_scope_to_scopes(_), do: [:wormholes]
defp add_character(
map_id,
%{id: character_id} = map_character,

View File

@@ -57,6 +57,12 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
@known_space [@hs, @ls, @ns, @pochven]
# Individual space type lists for granular scope matching
@hi_space [@hs]
@low_space [@ls]
@null_space [@ns]
@pochven_space [@pochven]
@prohibited_systems [@jita]
@prohibited_system_classes [
@a1,
@@ -100,7 +106,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
@connection_type_wormhole 0
@connection_type_stargate 1
@connection_type_bridge 2
# @connection_type_bridge 2 # reserved for future use
@medium_ship_size 1
def get_connection_auto_expire_hours(), do: WandererApp.Env.map_connection_auto_expire_hours()
@@ -403,7 +409,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 +650,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 +712,41 @@ 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
# Connection is valid if:
# 1. Neither system is prohibited
# 2. At least one system matches one of the selected scopes
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)) and
(system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes))
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 +760,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 +768,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")
@@ -859,14 +915,6 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
source_system_info.system_class == @c13 or target_system_info.system_class == @c13 ->
@frigate_ship_size
# C4 to null gets frigate (unless C4 is shattered)
(source_system_info.system_class == @c4 and target_system_info.system_class == @ns and
not source_system_info.is_shattered) or
(target_system_info.system_class == @c4 and
source_system_info.system_class == @ns and
not target_system_info.is_shattered) ->
@frigate_ship_size
true ->
# Default to large for other wormhole connections
@large_ship_size
@@ -909,9 +957,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

@@ -21,6 +21,7 @@ defmodule WandererApp.Map.Server.Impl do
:map_id,
:rtree_name,
map: nil,
acls: [],
map_opts: []
]
@@ -51,14 +52,15 @@ defmodule WandererApp.Map.Server.Impl do
Task.async(fn ->
{:map,
WandererApp.MapRepo.get(map_id, [
:owner,
:characters,
acls: [
:owner_id,
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
]
:owner
])}
end),
Task.async(fn ->
{:acls, WandererApp.Api.MapAccessList.read_by_map(%{map_id: map_id})}
end),
Task.async(fn ->
{:characters, WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id)}
end),
Task.async(fn ->
{:systems, WandererApp.MapSystemRepo.get_visible_by_map(map_id)}
end),
@@ -92,6 +94,18 @@ defmodule WandererApp.Map.Server.Impl do
_ -> nil
end)
acls_result =
Enum.find_value(results, fn
{:acls, result} -> result
_ -> nil
end)
characters_result =
Enum.find_value(results, fn
{:characters, result} -> result
_ -> nil
end)
systems_result =
Enum.find_value(results, fn
{:systems, result} -> result
@@ -112,12 +126,16 @@ defmodule WandererApp.Map.Server.Impl do
# Process results
with {:ok, map} <- map_result,
{:ok, acls} <- acls_result,
{:ok, characters} <- characters_result,
{:ok, systems} <- systems_result,
{:ok, connections} <- connections_result,
{:ok, subscription_settings} <- subscription_result do
initial_state
|> init_map(
map,
acls,
characters,
subscription_settings,
systems,
connections
@@ -129,7 +147,7 @@ defmodule WandererApp.Map.Server.Impl do
end
end
def start_map(%__MODULE__{map: map, map_id: map_id} = _state) do
def start_map(%__MODULE__{map: map, acls: acls, map_id: map_id} = _state) do
WandererApp.Cache.insert("map_#{map_id}:started", false)
# Check if map was loaded successfully
@@ -138,8 +156,8 @@ defmodule WandererApp.Map.Server.Impl do
Logger.error("Cannot start map #{map_id}: map not loaded")
{:error, :map_not_loaded}
map ->
with :ok <- AclsImpl.track_acls(map.acls |> Enum.map(& &1.id)) do
_map ->
with :ok <- AclsImpl.track_acls(acls |> Enum.map(& &1.access_list_id)) do
@pubsub_client.subscribe(
WandererApp.PubSub,
"maps:#{map_id}"
@@ -219,6 +237,7 @@ defmodule WandererApp.Map.Server.Impl do
defdelegate update_system_status(map_id, update), to: SystemsImpl
defdelegate update_system_tag(map_id, update), to: SystemsImpl
defdelegate update_system_temporary_name(map_id, update), to: SystemsImpl
defdelegate update_system_custom_name(map_id, update), to: SystemsImpl
defdelegate update_system_locked(map_id, update), to: SystemsImpl
defdelegate update_system_labels(map_id, update), to: SystemsImpl
defdelegate update_system_linked_sig_eve_id(map_id, update), to: SystemsImpl
@@ -288,6 +307,7 @@ defmodule WandererApp.Map.Server.Impl do
acc |> Map.put_new(connection_id, connection_start_time)
end)
# Create map state with retry logic for test scenarios
WandererApp.Api.MapState.create(%{
map_id: map_id,
systems_last_activity: systems_last_activity,
@@ -480,7 +500,9 @@ defmodule WandererApp.Map.Server.Impl do
defp init_map(
state,
%{id: map_id, characters: characters} = initial_map,
%{id: map_id} = initial_map,
acls,
characters,
subscription_settings,
systems,
connections
@@ -509,7 +531,7 @@ defmodule WandererApp.Map.Server.Impl do
WandererApp.Cache.insert("map_#{map_id}:invalidate_character_ids", character_ids)
%{state | map: map, map_opts: map_options(options)}
%{state | map: map, acls: acls, map_opts: map_options(options)}
end
def maybe_import_systems(
@@ -640,12 +662,45 @@ defmodule WandererApp.Map.Server.Impl do
not Enum.member?(presence_character_ids, character_id)
end)
# Log presence changes for debugging
if length(new_present_character_ids) > 0 or length(not_present_character_ids) > 0 do
Logger.debug(fn ->
"[MapServer] Map #{map_id} presence update - " <>
"newly_present: #{inspect(new_present_character_ids)}, " <>
"no_longer_present: #{inspect(not_present_character_ids)}, " <>
"total_present: #{length(presence_character_ids)}"
end)
end
WandererApp.Cache.insert(
"map_#{map_id}:old_presence_character_ids",
presence_character_ids
)
# Track new characters
if length(new_present_character_ids) > 0 do
Logger.debug(fn ->
"[MapServer] Map #{map_id} - starting tracking for #{length(new_present_character_ids)} newly present characters"
end)
end
CharactersImpl.track_characters(map_id, new_present_character_ids)
# Untrack characters no longer present (grace period has expired)
if length(not_present_character_ids) > 0 do
Logger.debug(fn ->
"[MapServer] Map #{map_id} - #{length(not_present_character_ids)} characters no longer in presence " <>
"(grace period expired or never had one) - will be untracked"
end)
# Emit telemetry for presence-based untracking
:telemetry.execute(
[:wanderer_app, :map, :presence, :characters_left],
%{count: length(not_present_character_ids), system_time: System.system_time()},
%{map_id: map_id, character_ids: not_present_character_ids}
)
end
CharactersImpl.untrack_characters(map_id, not_present_character_ids)
broadcast!(

View File

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

View File

@@ -106,7 +106,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
) do
system =
WandererApp.Map.find_system_by_location(map_id, %{
solar_system_id: solar_system_id |> String.to_integer()
solar_system_id: solar_system_id
})
{:ok, comment} =
@@ -118,7 +118,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
comment =
comment
|> Ash.load!([:character, :system])
|> Ash.load!([:character])
Impl.broadcast!(map_id, :system_comment_added, %{
solar_system_id: solar_system_id,
@@ -129,12 +129,14 @@ 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: system} = comment} =
{:ok, %{system_id: system_id} = comment} =
WandererApp.MapSystemCommentRepo.get_by_id(comment_id)
{:ok, system} = WandererApp.Api.MapSystem.by_id(system_id)
:ok = WandererApp.MapSystemCommentRepo.destroy(comment)
Impl.broadcast!(map_id, :system_comment_removed, %{
@@ -213,6 +215,12 @@ defmodule WandererApp.Map.Server.SystemsImpl do
),
do: update_system(map_id, :update_temporary_name, [:temporary_name], update)
def update_system_custom_name(
map_id,
update
),
do: update_system(map_id, :update_custom_name, [:custom_name], update)
def update_system_locked(
map_id,
update
@@ -301,7 +309,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
map_id
|> WandererApp.MapSystemRepo.remove_from_map(solar_system_id)
|> case do
{:ok, result} ->
{:ok, _result} ->
:ok = WandererApp.Map.remove_system(map_id, solar_system_id)
@ddrt.delete([solar_system_id], "rtree_#{map_id}")
Impl.broadcast!(map_id, :systems_removed, [solar_system_id])
@@ -395,13 +403,42 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|> Enum.each(fn s ->
try do
{:ok, %{eve_id: eve_id, system: system}} = s |> Ash.load([:system])
:ok = Ash.destroy!(s)
Logger.warning(
"[cleanup_linked_signatures] for system #{system.solar_system_id}: #{inspect(eve_id)}"
)
# Use Ash.destroy (not destroy!) to handle already-deleted signatures gracefully
case Ash.destroy(s) do
:ok ->
# Handle case where parent system was already deleted
case system do
nil ->
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} destroyed (parent system already deleted)"
end)
Impl.broadcast!(map_id, :signatures_updated, system.solar_system_id)
%{solar_system_id: solar_system_id} ->
Logger.debug(fn ->
"[cleanup_linked_signatures] for system #{solar_system_id}: #{inspect(eve_id)}"
end)
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
end
{:error, %Ash.Error.Invalid{errors: errors}} ->
# Check if this is a StaleRecord error (signature already deleted)
if Enum.any?(errors, &match?(%Ash.Error.Changes.StaleRecord{}, &1)) do
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} already deleted (StaleRecord)"
end)
else
Logger.error(
"[cleanup_linked_signatures] Failed to destroy signature #{eve_id}: #{inspect(errors)}"
)
end
{:error, error} ->
Logger.error(
"[cleanup_linked_signatures] Failed to destroy signature: #{inspect(error)}"
)
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
@@ -647,104 +684,145 @@ defmodule WandererApp.Map.Server.SystemsImpl do
user_id,
character_id
) do
extra_info = system_info |> Map.get(:extra_info)
rtree_name = "rtree_#{map_id}"
{:ok, %{map_opts: map_opts}} = WandererApp.Map.get_map_state(map_id)
# Verify the map exists in the database before attempting to create a system
# This prevents foreign key constraint errors when tests roll back transactions
with {:ok, _map} <- WandererApp.MapRepo.get(map_id),
{:ok, %{map_opts: map_opts}} <- WandererApp.Map.get_map_state(map_id) do
extra_info = system_info |> Map.get(:extra_info)
rtree_name = "rtree_#{map_id}"
%{"x" => x, "y" => y} =
coordinates
|> case do
%{"x" => x, "y" => y} ->
%{"x" => x, "y" => y}
%{"x" => x, "y" => y} =
coordinates
|> case do
%{"x" => x, "y" => y} ->
%{"x" => x, "y" => y}
_ ->
%{x: x, y: y} =
WandererApp.Map.PositionCalculator.get_new_system_position(nil, rtree_name, map_opts)
_ ->
%{x: x, y: y} =
WandererApp.Map.PositionCalculator.get_new_system_position(
nil,
rtree_name,
map_opts
)
%{"x" => x, "y" => y}
end
%{"x" => x, "y" => y}
end
{:ok, system} =
case WandererApp.MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do
{:ok, existing_system} when not is_nil(existing_system) ->
use_old_coordinates = Map.get(system_info, :use_old_coordinates, false)
system_result =
case WandererApp.MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do
{:ok, existing_system} when not is_nil(existing_system) ->
use_old_coordinates = Map.get(system_info, :use_old_coordinates, false)
if use_old_coordinates do
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: existing_system.position_x,
position_y: existing_system.position_y
})},
rtree_name
)
if use_old_coordinates do
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: existing_system.position_x,
position_y: existing_system.position_y
})},
rtree_name
)
existing_system
|> WandererApp.MapSystemRepo.update_visible(%{visible: true})
else
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: x,
position_y: y
})},
rtree_name
)
existing_system
|> WandererApp.MapSystemRepo.update_visible(%{visible: true})
else
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: x,
position_y: y
})},
rtree_name
)
existing_system
|> WandererApp.MapSystemRepo.update_position!(%{position_x: x, position_y: y})
|> WandererApp.MapSystemRepo.cleanup_labels!(map_opts)
|> WandererApp.MapSystemRepo.cleanup_tags!()
|> WandererApp.MapSystemRepo.cleanup_temporary_name!()
|> WandererApp.MapSystemRepo.cleanup_linked_sig_eve_id!()
|> maybe_update_extra_info(extra_info)
|> WandererApp.MapSystemRepo.update_visible(%{visible: true})
end
existing_system
|> WandererApp.MapSystemRepo.update_position!(%{position_x: x, position_y: y})
|> WandererApp.MapSystemRepo.cleanup_labels!(map_opts)
|> WandererApp.MapSystemRepo.cleanup_tags!()
|> WandererApp.MapSystemRepo.cleanup_temporary_name!()
|> WandererApp.MapSystemRepo.cleanup_linked_sig_eve_id!()
|> maybe_update_extra_info(extra_info)
|> WandererApp.MapSystemRepo.update_visible(%{visible: true})
end
_ ->
{:ok, solar_system_info} =
WandererApp.CachedInfo.get_system_static_info(solar_system_id)
_ ->
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do
{:ok, solar_system_info} ->
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: x,
position_y: y
})},
rtree_name
)
@ddrt.insert(
{solar_system_id,
WandererApp.Map.PositionCalculator.get_system_bounding_rect(%{
position_x: x,
position_y: y
})},
rtree_name
WandererApp.MapSystemRepo.create(%{
map_id: map_id,
solar_system_id: solar_system_id,
name: solar_system_info.solar_system_name,
position_x: x,
position_y: y
})
{:error, reason} ->
Logger.error(
"Failed to get system static info for #{solar_system_id}: #{inspect(reason)}"
)
{:error, :system_info_not_found}
end
end
case system_result do
{:ok, system} ->
:ok = WandererApp.Map.add_system(map_id, system)
WandererApp.Cache.put(
"map_#{map_id}:system_#{system.id}:last_activity",
DateTime.utc_now(),
ttl: @system_inactive_timeout
)
WandererApp.MapSystemRepo.create(%{
map_id: map_id,
solar_system_id: solar_system_id,
name: solar_system_info.solar_system_name,
position_x: x,
position_y: y
Impl.broadcast!(map_id, :add_system, system)
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
Logger.debug(fn ->
"SystemsImpl.do_add_system calling ExternalEvents.broadcast for map #{map_id}, system: #{solar_system_id}"
end)
WandererApp.ExternalEvents.broadcast(map_id, :add_system, %{
solar_system_id: system.solar_system_id,
position_x: system.position_x,
position_y: system.position_y
})
track_add_system(map_id, user_id, character_id, system.solar_system_id)
:ok
{:error, reason} = error ->
Logger.error(
"Failed to add system #{solar_system_id} to map #{map_id}: #{inspect(reason)}"
)
error
end
else
{:error, :not_found} ->
Logger.debug(fn ->
"Cannot add system #{solar_system_id} to map #{map_id}: map does not exist in database"
end)
:ok = WandererApp.Map.add_system(map_id, system)
{:error, :map_not_found}
WandererApp.Cache.put(
"map_#{map_id}:system_#{system.id}:last_activity",
DateTime.utc_now(),
ttl: @system_inactive_timeout
)
Impl.broadcast!(map_id, :add_system, system)
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
Logger.debug(fn ->
"SystemsImpl.do_add_system calling ExternalEvents.broadcast for map #{map_id}, system: #{solar_system_id}"
end)
WandererApp.ExternalEvents.broadcast(map_id, :add_system, %{
solar_system_id: system.solar_system_id,
name: system.name,
position_x: system.position_x,
position_y: system.position_y
})
error ->
Logger.error("Failed to verify map #{map_id} exists: #{inspect(error)}")
{:error, :map_verification_failed}
end
end
defp track_add_system(map_id, user_id, character_id, solar_system_id) do
WandererApp.User.ActivityTracker.track_map_event(:system_added, %{
character_id: character_id,
user_id: user_id,
@@ -815,10 +893,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
@@ -930,6 +1006,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
Impl.broadcast!(map_id, :update_system, updated_system)
# 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, %{
solar_system_id: updated_system.solar_system_id,
name: updated_system.name,
@@ -938,5 +1015,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
description: updated_system.description,
status: updated_system.status
})
:ok
end
end

View File

@@ -128,13 +128,18 @@ defmodule WandererApp.Maps do
tracked: tracked
}
defp get_map_characters(%{id: map_id} = map) do
defp get_map_characters(%{id: map_id} = _map) do
WandererApp.Cache.lookup!("map_characters-#{map_id}")
|> case do
nil ->
{:ok, acls} =
WandererApp.Api.MapAccessList.read_by_map(%{map_id: map_id},
load: [access_list: [:owner, :members]]
)
map_acls =
map.acls
|> Enum.map(fn acl -> acl |> Ash.load!(:members) end)
acls
|> Enum.map(fn acl -> acl.access_list end)
map_acl_owner_ids =
map_acls
@@ -169,9 +174,11 @@ defmodule WandererApp.Maps do
map_member_alliance_ids: map_member_alliance_ids
}
# Cache with 5 minute TTL so ACL changes are picked up even when map server isn't running
WandererApp.Cache.insert(
"map_characters-#{map_id}",
map_characters
map_characters,
ttl: :timer.minutes(5)
)
{:ok, map_characters}
@@ -198,10 +205,7 @@ defmodule WandererApp.Maps do
is_member_corp = to_string(c.corporation_id) in map_member_corporation_ids
is_member_alliance = to_string(c.alliance_id) in map_member_alliance_ids
has_access =
is_owner or is_acl_owner or is_member_eve or is_member_corp or is_member_alliance
has_access
is_owner || is_acl_owner || is_member_eve || is_member_corp || is_member_alliance
end)
end
@@ -245,11 +249,11 @@ defmodule WandererApp.Maps do
members ->
members
|> Enum.any?(fn member ->
(member.role == :blocked and
(member.role == :blocked &&
member.eve_character_id in user_character_eve_ids) or
(member.role == :blocked and
(member.role == :blocked &&
member.eve_corporation_id in user_character_corporation_ids) or
(member.role == :blocked and
(member.role == :blocked &&
member.eve_alliance_id in user_character_alliance_ids)
end)
end
@@ -332,9 +336,7 @@ defmodule WandererApp.Maps do
end
def check_user_can_delete_map(map_slug, current_user) do
map_slug
|> WandererApp.Api.Map.get_map_by_slug()
|> Ash.load([:owner, :acls, :user_permissions], actor: current_user)
WandererApp.MapRepo.get_by_slug_with_permissions(map_slug, current_user)
|> case do
{:ok,
%{

View File

@@ -1,6 +1,8 @@
defmodule WandererApp.MapCharacterSettingsRepo do
use WandererApp, :repository
require Logger
def get(map_id, character_id) do
case WandererApp.Api.MapCharacterSettings.read_by_map_and_character(%{
map_id: map_id,
@@ -53,22 +55,38 @@ defmodule WandererApp.MapCharacterSettingsRepo do
def get_tracked_by_map_all(map_id),
do: WandererApp.Api.MapCharacterSettings.tracked_by_map_all(%{map_id: map_id})
def track(settings) do
{:ok, _} = get(settings.map_id, settings.character_id)
# Only update the tracked field, preserving other fields
WandererApp.Api.MapCharacterSettings.track(%{
map_id: settings.map_id,
character_id: settings.character_id
})
def track(%{map_id: map_id, character_id: character_id}) do
# First ensure the record exists (get creates if not exists)
case get(map_id, character_id) do
{:ok, settings} when not is_nil(settings) ->
# Now update the tracked field
settings
|> WandererApp.Api.MapCharacterSettings.update(%{tracked: true})
error ->
Logger.error(
"Failed to track character: #{character_id} on map: #{map_id}, #{inspect(error)}"
)
{:error, error}
end
end
def untrack(settings) do
{:ok, _} = get(settings.map_id, settings.character_id)
# Only update the tracked field, preserving other fields
WandererApp.Api.MapCharacterSettings.untrack(%{
map_id: settings.map_id,
character_id: settings.character_id
})
def untrack(%{map_id: map_id, character_id: character_id}) do
# First ensure the record exists (get creates if not exists)
case get(map_id, character_id) do
{:ok, settings} when not is_nil(settings) ->
# Now update the tracked field
settings
|> WandererApp.Api.MapCharacterSettings.update(%{tracked: false})
error ->
Logger.error(
"Failed to untrack character: #{character_id} on map: #{map_id}, #{inspect(error)}"
)
{:error, error}
end
end
def track!(settings) do
@@ -85,18 +103,36 @@ defmodule WandererApp.MapCharacterSettingsRepo do
end
end
def follow(settings) do
WandererApp.Api.MapCharacterSettings.follow(%{
map_id: settings.map_id,
character_id: settings.character_id
})
def follow(%{map_id: map_id, character_id: character_id} = _settings) do
# First ensure the record exists (get creates if not exists)
case get(map_id, character_id) do
{:ok, settings} when not is_nil(settings) ->
settings
|> WandererApp.Api.MapCharacterSettings.update(%{followed: true})
error ->
Logger.error(
"Failed to follow character: #{character_id} on map: #{map_id}, #{inspect(error)}"
)
{:error, error}
end
end
def unfollow(settings) do
WandererApp.Api.MapCharacterSettings.unfollow(%{
map_id: settings.map_id,
character_id: settings.character_id
})
def unfollow(%{map_id: map_id, character_id: character_id} = _settings) do
# First ensure the record exists (get creates if not exists)
case get(map_id, character_id) do
{:ok, settings} when not is_nil(settings) ->
settings
|> WandererApp.Api.MapCharacterSettings.update(%{followed: false})
error ->
Logger.error(
"Failed to unfollow character: #{character_id} on map: #{map_id}, #{inspect(error)}"
)
{:error, error}
end
end
def follow!(settings) do

View File

@@ -97,9 +97,17 @@ defmodule WandererApp.MapConnectionRepo do
|> WandererApp.Api.MapConnection.update_custom_info(update)
def get_by_id(map_id, id) do
case WandererApp.Api.MapConnection.by_id(id) do
{:ok, conn} when conn.map_id == map_id -> {:ok, conn}
{:ok, _} -> {:error, :not_found}
# Use read_by_map action which doesn't have the FilterConnectionsByActorMap preparation
# that was causing "filter being false" errors in tests
require Ash.Query
WandererApp.Api.MapConnection
|> Ash.Query.for_read(:read_by_map, %{map_id: map_id})
|> Ash.Query.filter(id == ^id)
|> Ash.read_one()
|> case do
{:ok, nil} -> {:error, :not_found}
{:ok, conn} -> {:ok, conn}
{:error, _} -> {:error, :not_found}
end
end

View File

@@ -0,0 +1,56 @@
defmodule WandererApp.Repositories.MapContextHelper do
@moduledoc """
Helper for providing map context to Ash actions from internal callers.
When InjectMapFromActor is used, internal callers (map duplication, seeds, etc.)
need a way to provide map context without going through token auth.
This helper creates a minimal map struct for the context.
"""
@doc """
Build Ash context options from attributes containing map_id.
Returns a keyword list suitable for passing to Ash actions.
If attrs contains :map_id, creates a context with a minimal map struct.
If no map_id present, returns an empty list.
## Examples
iex> MapContextHelper.build_context(%{map_id: "123", name: "System"})
[context: %{map: %{id: "123"}}]
iex> MapContextHelper.build_context(%{name: "System"})
[]
iex> MapContextHelper.build_context(%{map_id: nil, name: "System"})
[]
"""
def build_context(attrs) when is_map(attrs) do
case Map.get(attrs, :map_id) do
nil -> []
map_id -> [context: %{map: %{id: map_id}}]
end
end
@doc """
Wraps an Ash action call with map context.
Deprecated: Use `build_context/1` instead for a simpler API.
## Examples
# Deprecated callback-based approach
MapContextHelper.with_map_context(%{map_id: "123", name: "System"}, fn attrs, context ->
WandererApp.Api.MapSystem.create(attrs, context)
end)
# Preferred approach using build_context/1
context = MapContextHelper.build_context(attrs)
WandererApp.Api.MapSystem.create(attrs, context)
"""
@deprecated "Use build_context/1 instead"
def with_map_context(attrs, fun) when is_map(attrs) and is_function(fun, 2) do
context = build_context(attrs)
fun.(attrs, context)
end
end

View File

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

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