mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-11-30 13:03:53 +00:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3de3c4e35 | ||
|
|
5c513f3e50 | ||
|
|
5a980c6b89 | ||
|
|
85c075c5a6 | ||
|
|
4585c3a94b | ||
|
|
cf2c27c961 | ||
|
|
f8e403025c | ||
|
|
46a1898be9 | ||
|
|
25fa7c07bc | ||
|
|
e7219e0eec | ||
|
|
45130fcffa | ||
|
|
5f75d4440d | ||
|
|
34210f63e3 | ||
|
|
5f60fd4922 | ||
|
|
47ef7dda55 | ||
|
|
0f3550a687 | ||
|
|
8f242f3535 | ||
|
|
1ce39e5394 | ||
|
|
cca7b912aa | ||
|
|
d939e32500 | ||
|
|
97ebe66db5 | ||
|
|
f437fc4541 | ||
|
|
6c65538450 | ||
|
|
d566a74df4 | ||
|
|
03e030a7d3 | ||
|
|
e738e1da9c | ||
|
|
972b3a6cbe | ||
|
|
96b4a3077e | ||
|
|
6b308e8a1e | ||
|
|
d0874cbc6f | ||
|
|
f106a51bf5 | ||
|
|
dc47dc5f81 | ||
|
|
dc81cffeea | ||
|
|
5766fcf4d8 | ||
|
|
c57a3b2cea | ||
|
|
0c1fa8e79b | ||
|
|
36cc91915c | ||
|
|
bb644fde31 | ||
|
|
269b54d382 | ||
|
|
a9115cc653 | ||
|
|
eeea7aee8b | ||
|
|
700089e381 | ||
|
|
932935557c | ||
|
|
2890a76cf2 | ||
|
|
4ac9b2e2b7 | ||
|
|
f92436f3f0 | ||
|
|
22d97cc99d | ||
|
|
305838573c | ||
|
|
cc7ad81d2f | ||
|
|
a694e57512 | ||
|
|
20be7fc67d | ||
|
|
54bfee414b | ||
|
|
bcfa47bd94 | ||
|
|
b784f68818 | ||
|
|
344ee54018 | ||
|
|
42e0f8f660 | ||
|
|
99b081887c | ||
|
|
b881c84a52 | ||
|
|
9e9dc39200 | ||
|
|
abd7e4e15c | ||
|
|
9666a8e78a | ||
|
|
271a3d90f8 | ||
|
|
01e291daf4 | ||
|
|
d39fa0363a | ||
|
|
a872561b18 | ||
|
|
857608f8ef | ||
|
|
7a74ae566b | ||
|
|
f2c8724763 | ||
|
|
9a8dc4dbe5 | ||
|
|
083e300ff5 | ||
|
|
ae4ebc0e36 | ||
|
|
c175f19142 | ||
|
|
0ebc703774 | ||
|
|
4615e20838 | ||
|
|
f4d28f282a | ||
|
|
1fe8ef17bd | ||
|
|
6088afb38c | ||
|
|
5764c41d23 | ||
|
|
09444596ff | ||
|
|
ee15d90f9c | ||
|
|
f5b014dae9 | ||
|
|
5e0965ead4 | ||
|
|
712379f4bb | ||
|
|
4c39c6fb39 | ||
|
|
a14e829f09 | ||
|
|
4002285882 | ||
|
|
d732d15ef6 | ||
|
|
7613ca78da | ||
|
|
c8631708b9 | ||
|
|
63ca473113 | ||
|
|
7df8284124 | ||
|
|
21ca630abd |
110
.github/workflows/test.yml
vendored
110
.github/workflows/test.yml
vendored
@@ -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.
|
||||
|
||||
125
CHANGELOG.md
125
CHANGELOG.md
@@ -2,6 +2,131 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* sse enable checkbox, and kills ticker
|
||||
|
||||
* apiv1 token auth and structure fixes
|
||||
|
||||
* removed ipv6 distribution env settings
|
||||
|
||||
* tests: updated tests
|
||||
|
||||
* tests: updated tests
|
||||
|
||||
* clean up id generation
|
||||
|
||||
* resolve issue with async event processing
|
||||
|
||||
## [v1.88.0](https://github.com/wanderer-industries/wanderer/compare/v1.87.0...v1.88.0) (2025-11-25)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* Add zkb and eve who links for characters where it possibly was add
|
||||
|
||||
## [v1.87.0](https://github.com/wanderer-industries/wanderer/compare/v1.86.1...v1.87.0) (2025-11-25)
|
||||
|
||||
|
||||
|
||||
52
Makefile
52
Makefile
@@ -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..."
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
}
|
||||
|
||||
.SidebarOnTheMap {
|
||||
width: 460px;
|
||||
width: 500px;
|
||||
padding: 0 !important;
|
||||
|
||||
:global {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import clsx from 'clsx';
|
||||
import classes from './PassageCard.module.scss';
|
||||
import { PassageWithSourceTarget } from '@/hooks/Mapper/types';
|
||||
import { SystemView, TimeAgo, TooltipPosition } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { SystemView, TimeAgo, TooltipPosition, WdImgButton } from '@/hooks/Mapper/components/ui-kit';
|
||||
import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper';
|
||||
import { kgToTons } from '@/hooks/Mapper/utils/kgToTons.ts';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { ZKB_ICON } from '@/hooks/Mapper/icons';
|
||||
import { charEveWhoLink, charZKBLink } from '@/hooks/Mapper/helpers/linkHelpers.ts';
|
||||
|
||||
type PassageCardType = {
|
||||
// compact?: boolean;
|
||||
@@ -33,6 +35,9 @@ export const PassageCard = ({ inserted_at, character: char, ship, source, target
|
||||
return date.toLocaleString();
|
||||
}, [inserted_at]);
|
||||
|
||||
const handleOpenZKB = useCallback(() => window.open(charZKBLink(char.eve_id), '_blank'), [char]);
|
||||
const handleOpenEveWho = useCallback(() => window.open(charEveWhoLink(char.eve_id), '_blank'), [char]);
|
||||
|
||||
return (
|
||||
<div className={clsx(classes.CharacterCard, 'w-full text-xs', 'flex flex-col box-border')}>
|
||||
<div className="flex flex-col justify-between px-2 py-1 gap-1">
|
||||
@@ -81,7 +86,7 @@ export const PassageCard = ({ inserted_at, character: char, ship, source, target
|
||||
{/*here name and ship name*/}
|
||||
<div className="grid gap-1 justify-between grid-cols-[max-content_1fr]">
|
||||
{/*char name*/}
|
||||
<div className="grid gap-1 grid-cols-[auto_1px_1fr]">
|
||||
<div className="grid gap-1 grid-cols-[auto_1px_1fr_auto]">
|
||||
<span
|
||||
className={clsx(classes.MaxWidth, 'text-ellipsis overflow-hidden whitespace-nowrap', {
|
||||
[classes.CardBorderLeftIsOwn]: isOwn,
|
||||
@@ -94,6 +99,21 @@ export const PassageCard = ({ inserted_at, character: char, ship, source, target
|
||||
<div className="h-3 border-r border-neutral-500 my-0.5"></div>
|
||||
{char.alliance_ticker && <span className="text-neutral-400">{char.alliance_ticker}</span>}
|
||||
{!char.alliance_ticker && <span className="text-neutral-400">{char.corporation_ticker}</span>}
|
||||
|
||||
<div className={clsx('flex gap-1 items-center h-full ml-[2px]')}>
|
||||
<WdImgButton
|
||||
width={16}
|
||||
height={16}
|
||||
tooltip={{ position: TooltipPosition.top, content: 'Open zkillboard' }}
|
||||
source={ZKB_ICON}
|
||||
onClick={handleOpenZKB}
|
||||
/>
|
||||
<WdImgButton
|
||||
tooltip={{ position: TooltipPosition.top, content: 'Open Eve Who' }}
|
||||
className={clsx('pi pi-user', '!text-[12px] relative top-[-1px]')}
|
||||
onClick={handleOpenEveWho}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*ship name*/}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
WdEveEntityPortrait,
|
||||
WdEveEntityPortraitSize,
|
||||
WdEveEntityPortraitType,
|
||||
WdImgButton,
|
||||
WdTooltipWrapper,
|
||||
} from '@/hooks/Mapper/components/ui-kit';
|
||||
import { SystemView } from '@/hooks/Mapper/components/ui-kit/SystemView';
|
||||
@@ -14,6 +15,8 @@ import { Commands } from '@/hooks/Mapper/types/mapHandlers';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import classes from './CharacterCard.module.scss';
|
||||
import { ZKB_ICON } from '@/hooks/Mapper/icons';
|
||||
import { charEveWhoLink, charZKBLink } from '@/hooks/Mapper/helpers/linkHelpers.ts';
|
||||
|
||||
export type CharacterCardProps = {
|
||||
compact?: boolean;
|
||||
@@ -66,6 +69,9 @@ export const CharacterCard = ({
|
||||
const shipType = char.ship?.ship_type_info?.name;
|
||||
const locationShown = showSystem && char.location?.solar_system_id;
|
||||
|
||||
const handleOpenZKB = useCallback(() => window.open(charZKBLink(char.eve_id), '_blank'), [char]);
|
||||
const handleOpenEveWho = useCallback(() => window.open(charEveWhoLink(char.eve_id), '_blank'), [char]);
|
||||
|
||||
// INFO: Simple mode show only name and icon of ally/corp. By default it compact view
|
||||
if (simpleMode) {
|
||||
return (
|
||||
@@ -244,7 +250,24 @@ export const CharacterCard = ({
|
||||
{char.name}
|
||||
</span>
|
||||
{showTicker && <span className="flex-shrink-0 text-gray-400 ml-1">[{tickerText}]</span>}
|
||||
|
||||
<div className={clsx('flex gap-1 items-center h-full ml-[6px]')}>
|
||||
<WdImgButton
|
||||
width={16}
|
||||
height={16}
|
||||
tooltip={{ position: TooltipPosition.top, content: 'Open zkillboard' }}
|
||||
source={ZKB_ICON}
|
||||
onClick={handleOpenZKB}
|
||||
className="min-w-[16px]"
|
||||
/>
|
||||
<WdImgButton
|
||||
tooltip={{ position: TooltipPosition.top, content: 'Open Eve Who' }}
|
||||
className={clsx('pi pi-user', '!text-[12px] relative top-[-1px]')}
|
||||
onClick={handleOpenEveWho}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locationShown ? (
|
||||
<div className="text-gray-300 text-xs overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<SystemView
|
||||
|
||||
2
assets/js/hooks/Mapper/helpers/linkHelpers.ts
Normal file
2
assets/js/hooks/Mapper/helpers/linkHelpers.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const charZKBLink = (characterId: string) => `https://zkillboard.com/character/${characterId}/`;
|
||||
export const charEveWhoLink = (characterId: string) => `https://evewho.com/character/${characterId}`;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -60,19 +60,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
|
||||
|
||||
|
||||
@@ -53,7 +53,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 +71,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
|
||||
|
||||
80
lib/wanderer_app/api/actor_helpers.ex
Normal file
80
lib/wanderer_app/api/actor_helpers.ex
Normal 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
|
||||
15
lib/wanderer_app/api/actor_with_map.ex
Normal file
15
lib/wanderer_app/api/actor_with_map.ex
Normal 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
|
||||
39
lib/wanderer_app/api/changes/inject_map_from_actor.ex
Normal file
39
lib/wanderer_app/api/changes/inject_map_from_actor.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,12 +81,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 +128,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
|
||||
require_atomic? false
|
||||
|
||||
accept([
|
||||
:tracked,
|
||||
:followed,
|
||||
:ship,
|
||||
:ship_name,
|
||||
:ship_item_id,
|
||||
@@ -145,8 +141,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 +154,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 +167,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 +180,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
|
||||
|
||||
@@ -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)
|
||||
@@ -73,7 +74,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,30 +160,37 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,7 +62,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 +92,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -59,12 +59,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
|
||||
|
||||
@@ -111,10 +111,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 +135,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
|
||||
|
||||
@@ -122,13 +122,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -53,22 +53,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
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ defmodule WandererApp.Api.MapWebhookSubscription do
|
||||
:consecutive_failures,
|
||||
:secret
|
||||
]
|
||||
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
read :by_map do
|
||||
|
||||
64
lib/wanderer_app/api/preparations/filter_by_actor_map.ex
Normal file
64
lib/wanderer_app/api/preparations/filter_by_actor_map.ex
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
62
lib/wanderer_app/api/preparations/secure_api_key_lookup.ex
Normal file
62
lib/wanderer_app/api/preparations/secure_api_key_lookup.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,7 +56,8 @@ defmodule WandererApp.Api.UserActivity do
|
||||
:entity_type,
|
||||
:event_type,
|
||||
:event_data,
|
||||
:user_id
|
||||
:user_id,
|
||||
:character_id
|
||||
]
|
||||
|
||||
read :read do
|
||||
@@ -70,14 +72,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \\ [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,11 @@ 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 +56,10 @@ 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 +75,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 +95,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 +145,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 +196,19 @@ 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 +247,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 +269,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 +340,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 +382,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 +419,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 +438,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 +473,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",
|
||||
|
||||
@@ -89,14 +89,4 @@ defmodule WandererApp.Character.TrackerPoolDynamicSupervisor do
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -114,8 +114,88 @@ defmodule WandererApp.Character.TrackingUtils do
|
||||
|
||||
# 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 +212,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 +231,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 +296,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ->
|
||||
@@ -414,7 +411,7 @@ 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)}
|
||||
_ -> {:ok, String.to_float(security) |> get_true_security() |> :erlang.float_to_binary(decimals: 1)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -496,23 +493,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
71
lib/wanderer_app/external_events/sse_access_control.ex
Normal file
71
lib/wanderer_app/external_events/sse_access_control.ex
Normal 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
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -180,14 +180,4 @@ defmodule WandererApp.Map.MapPoolDynamicSupervisor do
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,10 +93,8 @@ defmodule WandererApp.Map.Operations.Connections do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
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.
|
||||
"""
|
||||
# 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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,18 @@ 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,
|
||||
|
||||
@@ -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,17 @@ 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 +911,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 +953,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
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
@@ -397,11 +405,20 @@ defmodule WandererApp.Map.Server.SystemsImpl 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)}"
|
||||
)
|
||||
# Handle case where parent system was already deleted
|
||||
case system do
|
||||
nil ->
|
||||
Logger.warning(
|
||||
"[cleanup_linked_signatures] signature #{eve_id} destroyed (parent system already deleted)"
|
||||
)
|
||||
|
||||
Impl.broadcast!(map_id, :signatures_updated, system.solar_system_id)
|
||||
%{solar_system_id: solar_system_id} ->
|
||||
Logger.warning(
|
||||
"[cleanup_linked_signatures] for system #{solar_system_id}: #{inspect(eve_id)}"
|
||||
)
|
||||
|
||||
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
|
||||
@@ -647,104 +664,135 @@ 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 +863,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 +976,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 +985,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|
||||
description: updated_system.description,
|
||||
status: updated_system.status
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -198,10 +203,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 +247,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 +334,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,
|
||||
%{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
56
lib/wanderer_app/repositories/map_context_helper.ex
Normal file
56
lib/wanderer_app/repositories/map_context_helper.ex
Normal 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
|
||||
@@ -38,6 +38,4 @@ defmodule WandererApp.MapPingsRepo do
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
def destroy(_ping_id), do: :ok
|
||||
end
|
||||
|
||||
@@ -26,11 +26,20 @@ defmodule WandererApp.MapRepo do
|
||||
end
|
||||
end
|
||||
|
||||
def get_by_slug_with_permissions(map_slug, current_user),
|
||||
do:
|
||||
map_slug
|
||||
|> WandererApp.Api.Map.get_map_by_slug()
|
||||
|> load_user_permissions(current_user)
|
||||
def get_by_slug_with_permissions(map_slug, current_user) do
|
||||
map_slug
|
||||
|> WandererApp.Api.Map.get_map_by_slug!()
|
||||
|> Ash.load(
|
||||
acls: [
|
||||
:owner_id,
|
||||
members: [:role, :eve_character_id, :eve_corporation_id, :eve_alliance_id]
|
||||
]
|
||||
)
|
||||
|> case do
|
||||
{:ok, map_with_acls} -> Ash.load(map_with_acls, :user_permissions, actor: current_user)
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Safely retrieves a map by slug, handling the case where multiple maps
|
||||
@@ -60,16 +69,22 @@ defmodule WandererApp.MapRepo do
|
||||
handle_multiple_results(slug, multiple_results_error, retry_count)
|
||||
|
||||
:error ->
|
||||
# Some other Invalid error
|
||||
Logger.error("Error retrieving map by slug",
|
||||
slug: slug,
|
||||
error: inspect(error)
|
||||
)
|
||||
# Check if this is a no results error
|
||||
if is_no_results_error?(error) do
|
||||
Logger.debug("Map not found with slug: #{slug}")
|
||||
{:error, :not_found}
|
||||
else
|
||||
# Some other Invalid error
|
||||
Logger.error("Error retrieving map by slug",
|
||||
slug: slug,
|
||||
error: inspect(error)
|
||||
)
|
||||
|
||||
{:error, :unknown_error}
|
||||
{:error, :unknown_error}
|
||||
end
|
||||
end
|
||||
|
||||
error in Ash.Error.Query.NotFound ->
|
||||
_error in Ash.Error.Query.NotFound ->
|
||||
Logger.debug("Map not found with slug: #{slug}")
|
||||
{:error, :not_found}
|
||||
|
||||
@@ -142,17 +157,18 @@ defmodule WandererApp.MapRepo do
|
||||
end)
|
||||
end
|
||||
|
||||
# Helper function to check if an error indicates no results were found
|
||||
defp is_no_results_error?(%Ash.Error.Invalid{errors: errors}) do
|
||||
# If errors list is empty, it's likely a no results error
|
||||
Enum.empty?(errors)
|
||||
end
|
||||
|
||||
defp is_no_results_error?(_), do: false
|
||||
|
||||
def load_relationships(map, []), do: {:ok, map}
|
||||
|
||||
def load_relationships(map, relationships), do: map |> Ash.load(relationships)
|
||||
|
||||
defp load_user_permissions({:ok, map}, current_user),
|
||||
do:
|
||||
map
|
||||
|> Ash.load([:acls, :user_permissions], actor: current_user)
|
||||
|
||||
defp load_user_permissions(error, _current_user), do: error
|
||||
|
||||
def update_hubs(map_id, hubs) do
|
||||
map_id
|
||||
|> WandererApp.Api.Map.by_id()
|
||||
|
||||
@@ -4,10 +4,10 @@ defmodule WandererApp.MapSystemCommentRepo do
|
||||
require Logger
|
||||
|
||||
def get_by_id(comment_id),
|
||||
do: WandererApp.Api.MapSystemComment.by_id!(comment_id) |> Ash.load([:system])
|
||||
do: WandererApp.Api.MapSystemComment.by_id(comment_id)
|
||||
|
||||
def get_by_system(system_id),
|
||||
do: WandererApp.Api.MapSystemComment.by_system_id(system_id)
|
||||
do: WandererApp.Api.MapSystemComment.by_system_id(system_id, load: [:character])
|
||||
|
||||
def create(comment), do: comment |> WandererApp.Api.MapSystemComment.create()
|
||||
def create!(comment), do: comment |> WandererApp.Api.MapSystemComment.create!()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
defmodule WandererApp.MapSystemRepo do
|
||||
use WandererApp, :repository
|
||||
|
||||
alias WandererApp.Repositories.MapContextHelper
|
||||
|
||||
def create(system) do
|
||||
system |> WandererApp.Api.MapSystem.create()
|
||||
context = MapContextHelper.build_context(system)
|
||||
WandererApp.Api.MapSystem.create(system, context)
|
||||
end
|
||||
|
||||
def upsert(system) do
|
||||
@@ -10,12 +13,15 @@ defmodule WandererApp.MapSystemRepo do
|
||||
end
|
||||
|
||||
def get_by_map_and_solar_system_id(map_id, solar_system_id) do
|
||||
WandererApp.Api.MapSystem.by_map_id_and_solar_system_id(map_id, solar_system_id)
|
||||
WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_id
|
||||
})
|
||||
|> case do
|
||||
{:ok, system} ->
|
||||
{:ok, system}
|
||||
|
||||
_ ->
|
||||
_error ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
@@ -123,10 +129,16 @@ defmodule WandererApp.MapSystemRepo do
|
||||
system
|
||||
|> WandererApp.Api.MapSystem.update_description(update)
|
||||
|
||||
def update_locked(system, update),
|
||||
do:
|
||||
system
|
||||
|> WandererApp.Api.MapSystem.update_locked(update)
|
||||
def update_locked(system, update) do
|
||||
case WandererApp.Api.MapSystem.update_locked(system, update) do
|
||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.StaleRecord{}]}} ->
|
||||
WandererApp.Api.MapSystem.by_id!(system.id)
|
||||
|> WandererApp.Api.MapSystem.update_locked(update)
|
||||
|
||||
{:ok, system} ->
|
||||
{:ok, system}
|
||||
end
|
||||
end
|
||||
|
||||
def update_status(system, update),
|
||||
do:
|
||||
@@ -143,6 +155,11 @@ defmodule WandererApp.MapSystemRepo do
|
||||
|> WandererApp.Api.MapSystem.update_temporary_name(update)
|
||||
end
|
||||
|
||||
def update_custom_name(system, update) do
|
||||
system
|
||||
|> WandererApp.Api.MapSystem.update_custom_name(update)
|
||||
end
|
||||
|
||||
def update_labels(system, update),
|
||||
do:
|
||||
system
|
||||
|
||||
@@ -487,27 +487,21 @@ defmodule WandererApp.SecurityAudit do
|
||||
|
||||
# Private functions
|
||||
|
||||
defp store_audit_entry(_audit_entry) do
|
||||
# Handle async processing if enabled
|
||||
# if async_enabled?() do
|
||||
# WandererApp.SecurityAudit.AsyncProcessor.log_event(audit_entry)
|
||||
# else
|
||||
# do_store_audit_entry(audit_entry)
|
||||
# end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def do_store_audit_entry(audit_entry) do
|
||||
# Ensure event_type is properly formatted
|
||||
event_type = normalize_event_type(audit_entry.event_type)
|
||||
|
||||
# Generate unique entity_id to avoid constraint violations
|
||||
entity_id = generate_entity_id(audit_entry.session_id)
|
||||
|
||||
attrs = %{
|
||||
user_id: audit_entry.user_id,
|
||||
character_id: nil,
|
||||
entity_id: hash_identifier(audit_entry.session_id),
|
||||
entity_id: entity_id,
|
||||
entity_type: :security_event,
|
||||
event_type: event_type,
|
||||
event_data: encode_event_data(audit_entry)
|
||||
event_data: encode_event_data(audit_entry),
|
||||
user_id: audit_entry.user_id,
|
||||
character_id: nil
|
||||
}
|
||||
|
||||
case UserActivity.new(attrs) do
|
||||
@@ -619,13 +613,13 @@ defmodule WandererApp.SecurityAudit do
|
||||
defp convert_datetime(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||
defp convert_datetime(value), do: value
|
||||
|
||||
defp generate_entity_id do
|
||||
"audit_#{DateTime.utc_now() |> DateTime.to_unix(:microsecond)}_#{System.unique_integer([:positive])}"
|
||||
end
|
||||
|
||||
defp async_enabled? do
|
||||
Application.get_env(:wanderer_app, __MODULE__, [])
|
||||
|> Keyword.get(:async, false)
|
||||
defp generate_entity_id(session_id \\ nil) do
|
||||
if session_id do
|
||||
# Include high-resolution timestamp and unique component for guaranteed uniqueness
|
||||
"#{hash_identifier(session_id)}_#{:os.system_time(:microsecond)}_#{System.unique_integer([:positive])}"
|
||||
else
|
||||
"audit_#{:os.system_time(:microsecond)}_#{System.unique_integer([:positive])}"
|
||||
end
|
||||
end
|
||||
|
||||
defp emit_telemetry_event(audit_entry) do
|
||||
|
||||
@@ -88,20 +88,21 @@ defmodule WandererApp.SecurityAudit.AsyncProcessor do
|
||||
def handle_cast({:log_event, audit_entry}, state) do
|
||||
# Add to buffer
|
||||
buffer = [audit_entry | state.buffer]
|
||||
buf_len = length(buffer)
|
||||
|
||||
# Update stats
|
||||
stats = Map.update!(state.stats, :events_processed, &(&1 + 1))
|
||||
|
||||
# Check if we need to flush
|
||||
cond do
|
||||
length(buffer) >= state.batch_size ->
|
||||
buf_len >= state.batch_size ->
|
||||
# Flush immediately if batch size reached
|
||||
{:noreply, do_flush(%{state | buffer: buffer, stats: stats})}
|
||||
|
||||
length(buffer) >= @max_buffer_size ->
|
||||
buf_len >= @max_buffer_size ->
|
||||
# Force flush if max buffer size reached
|
||||
Logger.warning("Security audit buffer overflow, forcing flush",
|
||||
buffer_size: length(buffer),
|
||||
buffer_size: buf_len,
|
||||
max_size: @max_buffer_size
|
||||
)
|
||||
|
||||
@@ -186,23 +187,66 @@ defmodule WandererApp.SecurityAudit.AsyncProcessor do
|
||||
# Clear buffer
|
||||
%{state | buffer: [], stats: stats}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to flush security audit events",
|
||||
reason: inspect(reason),
|
||||
event_count: length(events)
|
||||
{:partial, success_count, failed_events} ->
|
||||
failed_count = length(failed_events)
|
||||
|
||||
Logger.warning(
|
||||
"Partial flush: stored #{success_count}, failed #{failed_count} audit events",
|
||||
success_count: success_count,
|
||||
failed_count: failed_count,
|
||||
buffer_size: length(state.buffer)
|
||||
)
|
||||
|
||||
# Emit telemetry for monitoring
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :security_audit, :async_flush_partial],
|
||||
%{success_count: success_count, failed_count: failed_count},
|
||||
%{}
|
||||
)
|
||||
|
||||
# Update stats - count partial flush as both success and error
|
||||
stats =
|
||||
state.stats
|
||||
|> Map.update!(:batches_flushed, &(&1 + 1))
|
||||
|> Map.update!(:errors, &(&1 + 1))
|
||||
|> Map.put(:last_flush, DateTime.utc_now())
|
||||
|
||||
# Extract just the events from failed_events tuples
|
||||
failed_only = Enum.map(failed_events, fn {event, _reason} -> event end)
|
||||
|
||||
remaining_buffer = Enum.reject(state.buffer, fn ev -> ev in failed_only end)
|
||||
|
||||
# Re-buffer failed events at the front, preserving newest-first ordering
|
||||
# Reverse failed_only since flush reversed the buffer to oldest-first
|
||||
new_buffer = Enum.reverse(failed_only) ++ remaining_buffer
|
||||
buffer = handle_buffer_overflow(new_buffer, @max_buffer_size)
|
||||
|
||||
%{state | buffer: buffer, stats: stats}
|
||||
|
||||
{:error, failed_events} ->
|
||||
failed_count = length(failed_events)
|
||||
|
||||
Logger.error("Failed to flush all #{failed_count} security audit events",
|
||||
failed_count: failed_count,
|
||||
buffer_size: length(state.buffer)
|
||||
)
|
||||
|
||||
# Emit telemetry for monitoring
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :security_audit, :async_flush_failure],
|
||||
%{count: 1, event_count: failed_count},
|
||||
%{}
|
||||
)
|
||||
|
||||
# Update error stats
|
||||
stats = Map.update!(state.stats, :errors, &(&1 + 1))
|
||||
|
||||
# Implement backoff - keep events in buffer but don't grow indefinitely
|
||||
buffer =
|
||||
if length(state.buffer) > @max_buffer_size do
|
||||
Logger.warning("Dropping oldest audit events due to repeated flush failures")
|
||||
Enum.take(state.buffer, @max_buffer_size)
|
||||
else
|
||||
state.buffer
|
||||
end
|
||||
# Extract just the events from failed_events tuples
|
||||
failed_only = Enum.map(failed_events, fn {event, _reason} -> event end)
|
||||
|
||||
# Since ALL events failed, the new buffer should only contain the failed events
|
||||
# Reverse to maintain newest-first ordering (flush reversed to oldest-first)
|
||||
buffer = handle_buffer_overflow(Enum.reverse(failed_only), @max_buffer_size)
|
||||
|
||||
%{state | buffer: buffer, stats: stats}
|
||||
end
|
||||
@@ -213,34 +257,100 @@ defmodule WandererApp.SecurityAudit.AsyncProcessor do
|
||||
events
|
||||
# Ash bulk operations work better with smaller chunks
|
||||
|> Enum.chunk_every(50)
|
||||
|> Enum.reduce_while({:ok, 0}, fn chunk, {:ok, count} ->
|
||||
|> Enum.reduce({0, []}, fn chunk, {total_success, all_failed} ->
|
||||
case store_event_chunk(chunk) do
|
||||
{:ok, chunk_count} ->
|
||||
{:cont, {:ok, count + chunk_count}}
|
||||
{total_success + chunk_count, all_failed}
|
||||
|
||||
{:error, _} = error ->
|
||||
{:halt, error}
|
||||
{:partial, chunk_count, failed_events} ->
|
||||
{total_success + chunk_count, all_failed ++ failed_events}
|
||||
|
||||
{:error, failed_events} ->
|
||||
{total_success, all_failed ++ failed_events}
|
||||
end
|
||||
end)
|
||||
|> then(fn {success_count, failed_events_list} ->
|
||||
# Derive the final return shape based on results
|
||||
cond do
|
||||
failed_events_list == [] ->
|
||||
{:ok, success_count}
|
||||
|
||||
success_count == 0 ->
|
||||
{:error, failed_events_list}
|
||||
|
||||
true ->
|
||||
{:partial, success_count, failed_events_list}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp handle_buffer_overflow(buffer, max_size) when length(buffer) > max_size do
|
||||
dropped = length(buffer) - max_size
|
||||
|
||||
Logger.warning(
|
||||
"Dropping #{dropped} oldest audit events due to buffer overflow",
|
||||
buffer_size: length(buffer),
|
||||
max_size: max_size
|
||||
)
|
||||
|
||||
# Emit telemetry for dropped events
|
||||
:telemetry.execute(
|
||||
[:wanderer_app, :security_audit, :events_dropped],
|
||||
%{count: dropped},
|
||||
%{}
|
||||
)
|
||||
|
||||
# Keep the newest events (take from the front since buffer is newest-first)
|
||||
Enum.take(buffer, max_size)
|
||||
end
|
||||
|
||||
defp handle_buffer_overflow(buffer, _max_size), do: buffer
|
||||
|
||||
defp store_event_chunk(events) do
|
||||
# Transform events to Ash attributes
|
||||
records =
|
||||
Enum.map(events, fn event ->
|
||||
SecurityAudit.do_store_audit_entry(event)
|
||||
# Process each event and partition results
|
||||
{successes, failures} =
|
||||
events
|
||||
|> Enum.map(fn event ->
|
||||
case SecurityAudit.do_store_audit_entry(event) do
|
||||
:ok ->
|
||||
{:ok, event}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to store individual audit event",
|
||||
error: inspect(reason),
|
||||
event_type: Map.get(event, :event_type),
|
||||
user_id: Map.get(event, :user_id)
|
||||
)
|
||||
|
||||
{:error, {event, reason}}
|
||||
end
|
||||
end)
|
||||
|> Enum.split_with(fn
|
||||
{:ok, _} -> true
|
||||
{:error, _} -> false
|
||||
end)
|
||||
|
||||
# Count successful stores
|
||||
successful =
|
||||
Enum.count(records, fn
|
||||
:ok -> true
|
||||
_ -> false
|
||||
end)
|
||||
successful_count = length(successes)
|
||||
failed_count = length(failures)
|
||||
|
||||
{:ok, successful}
|
||||
rescue
|
||||
error ->
|
||||
{:error, error}
|
||||
# Extract failed events with reasons
|
||||
failed_events = Enum.map(failures, fn {:error, event_reason} -> event_reason end)
|
||||
|
||||
# Log if some events failed (telemetry will be emitted at flush level)
|
||||
if failed_count > 0 do
|
||||
Logger.debug("Chunk processing: #{failed_count} of #{length(events)} events failed")
|
||||
end
|
||||
|
||||
# Return richer result shape
|
||||
cond do
|
||||
successful_count == 0 ->
|
||||
{:error, failed_events}
|
||||
|
||||
failed_count > 0 ->
|
||||
{:partial, successful_count, failed_events}
|
||||
|
||||
true ->
|
||||
{:ok, successful_count}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user