Compare commits

..

1 Commits

Author SHA1 Message Date
Aleksei Chichenkov
646262447d Revert "Develop" 2025-11-26 22:10:03 +03:00
280 changed files with 2321 additions and 17336 deletions

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

@@ -2,389 +2,6 @@
<!-- changelog -->
## [v1.92.0](https://github.com/wanderer-industries/wanderer/compare/v1.91.11...v1.92.0) (2026-01-14)
### Features:
* Added ability to select a range of wh classes for k162.
### Bug Fixes:
* core: Show c1/c2/c3 or c4/c5 or link signature modal
## [v1.91.11](https://github.com/wanderer-industries/wanderer/compare/v1.91.10...v1.91.11) (2026-01-13)
### Bug Fixes:
* allow sig api when map relay is off
## [v1.91.10](https://github.com/wanderer-industries/wanderer/compare/v1.91.9...v1.91.10) (2026-01-07)
### Bug Fixes:
* remove actor context requirement from sig api
## [v1.91.9](https://github.com/wanderer-industries/wanderer/compare/v1.91.8...v1.91.9) (2026-01-06)
### Bug Fixes:
* core: fixed rally point cancel logic
## [v1.91.8](https://github.com/wanderer-industries/wanderer/compare/v1.91.7...v1.91.8) (2026-01-06)
### Bug Fixes:
* core: fixed rally point cancel logic
## [v1.91.7](https://github.com/wanderer-industries/wanderer/compare/v1.91.6...v1.91.7) (2026-01-05)
## [v1.91.6](https://github.com/wanderer-industries/wanderer/compare/v1.91.5...v1.91.6) (2026-01-04)
### Bug Fixes:
* core: fixed new connections got deleted after linked signature cleanup
## [v1.91.5](https://github.com/wanderer-industries/wanderer/compare/v1.91.4...v1.91.5) (2025-12-30)
## [v1.91.4](https://github.com/wanderer-industries/wanderer/compare/v1.91.3...v1.91.4) (2025-12-30)
### Bug Fixes:
* core: fixed connections create between k-space systems (considered as wh connection)
## [v1.91.3](https://github.com/wanderer-industries/wanderer/compare/v1.91.2...v1.91.3) (2025-12-28)
## [v1.91.2](https://github.com/wanderer-industries/wanderer/compare/v1.91.1...v1.91.2) (2025-12-27)
### Bug Fixes:
* core: fixed map scopes updates & logic
## [v1.91.1](https://github.com/wanderer-industries/wanderer/compare/v1.91.0...v1.91.1) (2025-12-25)
## [v1.91.0](https://github.com/wanderer-industries/wanderer/compare/v1.90.13...v1.91.0) (2025-12-24)
### Features:
* admin: added maps administration view with basic info, search, restore/delete, acls view and edit options
## [v1.90.13](https://github.com/wanderer-industries/wanderer/compare/v1.90.12...v1.90.13) (2025-12-19)
### Bug Fixes:
* core: fixed welcome page
## [v1.90.12](https://github.com/wanderer-industries/wanderer/compare/v1.90.11...v1.90.12) (2025-12-19)
### Bug Fixes:
* core: fixed permissions update after character corp updates
## [v1.90.11](https://github.com/wanderer-industries/wanderer/compare/v1.90.10...v1.90.11) (2025-12-18)
## [v1.90.10](https://github.com/wanderer-industries/wanderer/compare/v1.90.9...v1.90.10) (2025-12-18)
## [v1.90.9](https://github.com/wanderer-industries/wanderer/compare/v1.90.8...v1.90.9) (2025-12-15)
### Bug Fixes:
* core: reduce chracters untrack grace period to 15 mins (after change/close/disconnect from map)
## [v1.90.8](https://github.com/wanderer-industries/wanderer/compare/v1.90.7...v1.90.8) (2025-12-15)
### Bug Fixes:
* core: skip systems or connections cleanup for not started maps
## [v1.90.7](https://github.com/wanderer-industries/wanderer/compare/v1.90.6...v1.90.7) (2025-12-15)
### Bug Fixes:
* core: fixed scopes
## [v1.90.6](https://github.com/wanderer-industries/wanderer/compare/v1.90.5...v1.90.6) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes
## [v1.90.5](https://github.com/wanderer-industries/wanderer/compare/v1.90.4...v1.90.5) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes
## [v1.90.4](https://github.com/wanderer-industries/wanderer/compare/v1.90.3...v1.90.4) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes & signatures clean up behaviour
## [v1.90.3](https://github.com/wanderer-industries/wanderer/compare/v1.90.2...v1.90.3) (2025-12-11)
### Bug Fixes:
* core: added pagination for long ACL lists
## [v1.90.2](https://github.com/wanderer-industries/wanderer/compare/v1.90.1...v1.90.2) (2025-12-10)
### Bug Fixes:
* core: added system position updates to SSE
## [v1.90.1](https://github.com/wanderer-industries/wanderer/compare/v1.90.0...v1.90.1) (2025-12-08)
### Bug Fixes:
* core: fixed connections and signatures remove issues, added comprehensive audit log for auto removed connections and signatures
## [v1.90.0](https://github.com/wanderer-industries/wanderer/compare/v1.89.6...v1.90.0) (2025-12-06)
### Features:
* core: Added several map scopes support (Wh, Hi, Low, Null, Pochven)
### Bug Fixes:
* core: fixed clean up for linked signatures
* core: fixed issue with default select mode
* apiV1 default fields updates
## [v1.89.6](https://github.com/wanderer-industries/wanderer/compare/v1.89.5...v1.89.6) (2025-12-02)
### Bug Fixes:
* kills: fixed zkb links (added "allow-popups-to-escape-sandbox" to CSP)
## [v1.89.5](https://github.com/wanderer-industries/wanderer/compare/v1.89.4...v1.89.5) (2025-12-02)
## [v1.89.4](https://github.com/wanderer-industries/wanderer/compare/v1.89.3...v1.89.4) (2025-12-02)
### Bug Fixes:
* core: fixed acl character update issues
## [v1.89.3](https://github.com/wanderer-industries/wanderer/compare/v1.89.2...v1.89.3) (2025-11-30)
### Bug Fixes:
* core: fixed tracking issues
## [v1.89.2](https://github.com/wanderer-industries/wanderer/compare/v1.89.1...v1.89.2) (2025-11-30)
## [v1.89.1](https://github.com/wanderer-industries/wanderer/compare/v1.89.0...v1.89.1) (2025-11-30)
### Bug Fixes:
* core: fixed tracking issues
## [v1.89.0](https://github.com/wanderer-industries/wanderer/compare/v1.88.13...v1.89.0) (2025-11-30)
### Features:
* removed unnecessary command
* rework wormholes reference
## [v1.88.13](https://github.com/wanderer-industries/wanderer/compare/v1.88.12...v1.88.13) (2025-11-29)
### Bug Fixes:
* core: fixed tracking issues
## [v1.88.12](https://github.com/wanderer-industries/wanderer/compare/v1.88.11...v1.88.12) (2025-11-29)
### Bug Fixes:
* core: fixed c4 -> ns connections auto size issues
## [v1.88.11](https://github.com/wanderer-industries/wanderer/compare/v1.88.10...v1.88.11) (2025-11-29)
## [v1.88.10](https://github.com/wanderer-industries/wanderer/compare/v1.88.9...v1.88.10) (2025-11-29)
### Bug Fixes:
* core: fixed pings cleanup
## [v1.88.9](https://github.com/wanderer-industries/wanderer/compare/v1.88.8...v1.88.9) (2025-11-29)
### Bug Fixes:
* core: fixed linked signatures cleanup
## [v1.88.8](https://github.com/wanderer-industries/wanderer/compare/v1.88.7...v1.88.8) (2025-11-28)
### Bug Fixes:
* core: fixed pings issue
## [v1.88.7](https://github.com/wanderer-industries/wanderer/compare/v1.88.6...v1.88.7) (2025-11-28)
### Bug Fixes:
* core: fixed tracking issues
## [v1.88.6](https://github.com/wanderer-industries/wanderer/compare/v1.88.5...v1.88.6) (2025-11-28)
### Bug Fixes:
* core: fixed tracking issues
## [v1.88.5](https://github.com/wanderer-industries/wanderer/compare/v1.88.4...v1.88.5) (2025-11-28)
### Bug Fixes:
* core: fixed env errors
## [v1.88.4](https://github.com/wanderer-industries/wanderer/compare/v1.88.3...v1.88.4) (2025-11-27)
### Bug Fixes:
* defensive check for undefined excluded systems
## [v1.88.3](https://github.com/wanderer-industries/wanderer/compare/v1.88.2...v1.88.3) (2025-11-26)
### Bug Fixes:
* core: fixed env issues
## [v1.88.1](https://github.com/wanderer-industries/wanderer/compare/v1.88.0...v1.88.1) (2025-11-26)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,7 +121,6 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
useEffect(() => {
if (!ping) {
setIsShow(false);
return;
}
@@ -162,26 +161,27 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
};
}, [interfaceSettings]);
const isShowSelectedSystem = ping && selectedSystem != null && selectedSystem !== ping.solar_system_id;
if (!ping) {
return null;
}
const isShowSelectedSystem = selectedSystem != null && selectedSystem !== ping.solar_system_id;
// Only render Toast when there's a ping
return (
<>
{ping && (
<Toast
key={ping.id}
position={placement as never}
className={clsx('!max-w-[initial] w-[500px]', hasLeftOffset ? offsets.withLeftMenu : offsets.default)}
ref={toast}
content={({ message }) => (
<section
className={clsx(
'flex flex-col p-3 w-full border border-stone-800 shadow-md animate-fadeInDown rounded-[5px]',
'bg-gradient-to-tr from-transparent to-sky-700/60 bg-stone-900/70',
)}
>
<div className="flex gap-3">
<i className={clsx('pi text-yellow-500 text-2xl', 'relative top-[2px]', ICONS[ping.type])}></i>
<Toast
position={placement as never}
className={clsx('!max-w-[initial] w-[500px]', hasLeftOffset ? offsets.withLeftMenu : offsets.default)}
ref={toast}
content={({ message }) => (
<section
className={clsx(
'flex flex-col p-3 w-full border border-stone-800 shadow-md animate-fadeInDown rounded-[5px]',
'bg-gradient-to-tr from-transparent to-sky-700/60 bg-stone-900/70',
)}
>
<div className="flex gap-3">
<i className={clsx('pi text-yellow-500 text-2xl', 'relative top-[2px]', ICONS[ping.type])}></i>
<div className="flex flex-col gap-1 w-full">
<div className="flex justify-between">
<div>
@@ -253,33 +253,28 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
{/*/>*/}
</div>
</section>
)}
></Toast>
)}
)}
></Toast>
{ping && (
<>
<WdButton
icon="pi pi-bell"
severity="warning"
aria-label="Notification"
size="small"
className="w-[33px] h-[33px]"
outlined
onClick={handleClickShow}
disabled={isShow}
/>
<WdButton
icon="pi pi-bell"
severity="warning"
aria-label="Notification"
size="small"
className="w-[33px] h-[33px]"
outlined
onClick={handleClickShow}
disabled={isShow}
/>
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Are you sure you want to delete ping?"
icon="pi pi-exclamation-triangle text-orange-400"
accept={removePing}
/>
</>
)}
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Are you sure you want to delete ping?"
icon="pi pi-exclamation-triangle text-orange-400"
accept={removePing}
/>
</>
);
};

View File

@@ -3,9 +3,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSystemInfo } from '@/hooks/Mapper/components/hooks';
import {
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
SOLAR_SYSTEM_CLASS_IDS,
SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS,
WORMHOLES_ADDITIONAL_INFO_BY_SHORT_NAME,
} from '@/hooks/Mapper/components/map/constants.ts';
import { SystemSignaturesContent } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/SystemSignaturesContent';
import { K162_TYPES_MAP } from '@/hooks/Mapper/constants.ts';
@@ -91,7 +91,7 @@ export const SystemLinkSignatureDialog = ({ data, setVisible }: SystemLinkSignat
if (k162TypeInfo) {
// Check if the k162Type matches our target system class
return k162TypeInfo.value.includes(targetSystemClassGroup);
return customInfo.k162Type === targetSystemClassGroup;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,26 +13,6 @@ export const renderK162Type = (option: K162Type) => {
return renderNoValue();
}
if (['c1_c2_c3', 'c4_c5'].includes(value)) {
const arr = whClassName.split('_');
return (
<div className="flex gap-1 items-center">
{arr.map(x => (
<WHClassView
key={x}
classNameWh="!text-[11px] !font-bold"
hideWhClassName
hideTooltip
whClassName={x}
noOffset
useShortTitle
/>
))}
</div>
);
}
return (
<WHClassView
classNameWh="!text-[11px] !font-bold"

View File

@@ -1,9 +1,8 @@
import { createContext, useCallback, useContext, useRef, useState, useEffect } from 'react';
import { Commands, OutCommand, TrackingCharacter } from '@/hooks/Mapper/types';
import { createContext, useCallback, useContext, useRef, useState } from 'react';
import { 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 };
@@ -123,14 +122,6 @@ export const TrackingProvider = ({ children }: WithChildren) => {
[outCommand],
);
// Listen for refresh_tracking_data event (triggered when ACL members change)
useMapEventListener(event => {
if (event.name === Commands.refreshTrackingData) {
loadTracking();
return true;
}
});
return (
<TrackingContext.Provider
value={{

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,16 +88,6 @@ export const K162_TYPES: K162Type[] = [
value: 'ns',
whClassName: 'C248',
},
{
label: 'C1/C2/C3',
value: 'c1_c2_c3',
whClassName: 'E004_D382_L477',
},
{
label: 'C4/C5',
value: 'c4_c5',
whClassName: 'M001_L614',
},
{
label: 'C1',
value: 'c1',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,10 +38,8 @@ export enum Commands {
updateTracking = 'update_tracking',
userSettingsUpdated = 'user_settings_updated',
showTracking = 'show_tracking',
refreshTrackingData = 'refresh_tracking_data',
pingAdded = 'ping_added',
pingCancelled = 'ping_cancelled',
pingBlocked = 'ping_blocked',
}
export type Command =
@@ -76,10 +74,8 @@ export type Command =
| Commands.updateActivity
| Commands.updateTracking
| Commands.showTracking
| Commands.refreshTrackingData
| Commands.pingAdded
| Commands.pingCancelled
| Commands.pingBlocked;
| Commands.pingCancelled;
export type CommandInit = {
systems: SolarSystemRawType[];
@@ -135,7 +131,7 @@ export type CommandLinkSignatureToSystem = {
};
export type CommandLinkSignaturesUpdated = number;
export type CommandCommentAdd = {
solarSystemId: number;
solarSystemId: string;
comment: CommentType;
};
export type CommandCommentRemoved = {
@@ -149,7 +145,6 @@ export type CommandUserSettingsUpdated = {
};
export type CommandShowTracking = null;
export type CommandRefreshTrackingData = Record<string, never>;
export type CommandUpdateActivity = {
characterId: number;
systemId: number;
@@ -163,10 +158,6 @@ export type CommandUpdateTracking = {
};
export type CommandPingAdded = PingData[];
export type CommandPingCancelled = Pick<PingData, 'type' | 'id'>;
export type CommandPingBlocked = {
reason: string;
message: string;
};
export interface UserSettings {
primaryCharacterId?: string;
@@ -215,10 +206,8 @@ export interface CommandData {
[Commands.systemCommentRemoved]: CommandCommentRemoved;
[Commands.systemCommentsUpdated]: unknown;
[Commands.showTracking]: CommandShowTracking;
[Commands.refreshTrackingData]: CommandRefreshTrackingData;
[Commands.pingAdded]: CommandPingAdded;
[Commands.pingCancelled]: CommandPingCancelled;
[Commands.pingBlocked]: CommandPingBlocked;
}
export interface MapHandlers {

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

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

View File

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

View File

@@ -92,31 +92,6 @@ map_subscription_extra_hubs_10_price =
config_dir
|> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_EXTRA_HUBS_10_PRICE", 10_000_000)
# Parse promo codes from environment variable
# Format: "CODE1:10,CODE2:20" where numbers are discount percentages
promo_codes =
config_dir
|> get_var_from_path_or_env("WANDERER_PROMO_CODES", "")
|> case do
"" ->
%{}
codes_string ->
codes_string
|> String.split(",")
|> Enum.map(fn entry ->
case String.split(String.trim(entry), ":") do
[code, discount] ->
{String.upcase(String.trim(code)), String.to_integer(String.trim(discount))}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
end
map_connection_auto_expire_hours =
config_dir
|> get_int_from_path_or_env("WANDERER_MAP_CONNECTION_AUTO_EXPIRE_HOURS", 24)
@@ -201,8 +176,7 @@ config :wanderer_app,
}
],
extra_characters_50: map_subscription_extra_characters_50_price,
extra_hubs_10: map_subscription_extra_hubs_10_price,
promo_codes: promo_codes
extra_hubs_10: map_subscription_extra_hubs_10_price
},
# Finch pool configuration - separate pools for different services
# ESI Character Tracking pool - high capacity for bulk character operations
@@ -290,7 +264,7 @@ config :logger,
case config_env() do
:prod -> "info"
:dev -> "info"
:test -> "warning"
:test -> "debug"
end
)
)
@@ -458,7 +432,7 @@ config :wanderer_app, :license_manager,
config :wanderer_app, :sse,
enabled:
config_dir
|> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "false")
|> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "true")
|> String.to_existing_atom(),
max_connections_total:
config_dir |> get_int_from_path_or_env("WANDERER_SSE_MAX_CONNECTIONS", 1000),
@@ -473,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", "false")
|> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "true")
|> String.to_existing_atom(),
webhook_timeout_ms: config_dir |> get_int_from_path_or_env("WANDERER_WEBHOOK_TIMEOUT_MS", 15000)

View File

@@ -1,9 +1,5 @@
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
@@ -28,11 +24,7 @@ config :wanderer_app,
pubsub_client: Test.PubSubMock,
cached_info: WandererApp.CachedInfo.Mock,
character_api_disabled: false,
environment: :test,
map_subscriptions_enabled: false,
wanderer_kills_service_enabled: false,
sse: [enabled: false],
external_events: [webhooks_enabled: false]
environment: :test
# We don't run a server during test. If one is required,
# you can enable the server option below.

View File

@@ -16,11 +16,6 @@ defmodule WandererApp.Api.AccessList do
includes([:owner, :members])
default_fields([
:name,
:description
])
derive_filter?(true)
derive_sort?(true)
@@ -65,17 +60,19 @@ 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
@@ -84,15 +81,12 @@ defmodule WandererApp.Api.AccessList do
attribute :name, :string do
allow_nil? false
public? true
end
attribute :description, :string do
allow_nil? true
public? true
end
# Note: api_key intentionally not public for security
attribute :api_key, :string do
allow_nil? true
end

View File

@@ -16,14 +16,6 @@ defmodule WandererApp.Api.AccessListMember do
includes([:access_list])
default_fields([
:name,
:eve_character_id,
:eve_corporation_id,
:eve_alliance_id,
:role
])
derive_filter?(true)
derive_sort?(true)
@@ -61,11 +53,7 @@ defmodule WandererApp.Api.AccessListMember do
:role
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
read :read_by_access_list do
argument(:access_list_id, :string, allow_nil?: false)
@@ -79,14 +67,12 @@ 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
@@ -97,27 +83,22 @@ defmodule WandererApp.Api.AccessListMember do
attribute :name, :string do
allow_nil? false
public? true
end
attribute :eve_character_id, :string do
allow_nil? true
public? true
end
attribute :eve_corporation_id, :string do
allow_nil? true
public? true
end
attribute :eve_alliance_id, :string do
allow_nil? true
public? true
end
attribute :role, :atom do
default "viewer"
public? true
constraints(
one_of: [

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
defmodule WandererApp.Api.Changes.InjectMapFromActor do
@moduledoc """
Ash change that injects map_id from the authenticated actor.
For token-based auth, the map is determined by the API token.
This change automatically sets map_id, so clients don't need to provide it.
"""
use Ash.Resource.Change
alias WandererApp.Api.ActorHelpers
@impl true
def change(changeset, _opts, context) do
case ActorHelpers.get_map(context) do
%{id: map_id} ->
Ash.Changeset.force_change_attribute(changeset, :map_id, map_id)
_other ->
# nil or unexpected return shape - check for direct map_id
# Check params (input), arguments, and attributes (in that order)
map_id =
Map.get(changeset.params, :map_id) ||
Ash.Changeset.get_argument(changeset, :map_id) ||
Ash.Changeset.get_attribute(changeset, :map_id)
case map_id do
nil ->
Ash.Changeset.add_error(changeset,
field: :map_id,
message: "map_id is required (provide via token or attribute)"
)
_map_id ->
# map_id provided directly (internal calls, tests)
changeset
end
end
end
end

View File

@@ -69,6 +69,11 @@ 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)
@@ -95,7 +100,6 @@ 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))
@@ -103,7 +107,6 @@ defmodule WandererApp.Api.Character do
update :update_online do
accept([:online])
require_atomic? false
end
update :update_location do

View File

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

View File

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

View File

@@ -8,13 +8,9 @@ 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
@@ -48,7 +44,6 @@ 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)
@@ -59,7 +54,6 @@ 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],
@@ -67,8 +61,6 @@ defmodule WandererApp.Api.Map do
)
define(:duplicate, action: :duplicate)
define(:admin_all, action: :admin_all)
define(:restore, action: :restore)
end
calculations do
@@ -98,41 +90,22 @@ 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
read :admin_all do
# Admin-only action that bypasses FilterMapsByRoles
# Returns ALL maps including soft-deleted ones with owner and ACLs loaded
prepare build(load: [:owner, :acls])
end
create :new do
accept [
:name,
:slug,
:description,
:scope,
:scopes,
:only_tracked_characters,
:owner_id,
:sse_enabled
]
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id]
primary?(true)
argument :owner_id, :uuid, allow_nil?: false
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
@@ -140,17 +113,7 @@ defmodule WandererApp.Api.Map do
update :update do
primary? true
require_atomic? false
accept [
:name,
:slug,
:description,
:scope,
:scopes,
:only_tracked_characters,
:owner_id,
:sse_enabled
]
accept [:name, :slug, :description, :scope, :only_tracked_characters, :owner_id]
argument :owner_id_text_input, :string, allow_nil?: true
argument :acls_text_input, :string, allow_nil?: true
@@ -165,9 +128,6 @@ 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
@@ -182,54 +142,33 @@ 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 :restore do
# Admin-only action to restore a soft-deleted map
accept([])
require_atomic? false
change(set_attribute(:deleted, false))
end
update :update_api_key do
accept [:public_api_key]
require_atomic? false
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, :scopes, :only_tracked_characters]
accept [:name, :description, :scope, :only_tracked_characters]
argument :source_map_id, :uuid, allow_nil?: false
argument :copy_acls, :boolean, default: true
argument :copy_user_settings, :boolean, default: true
@@ -245,14 +184,9 @@ 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
@@ -378,37 +312,12 @@ 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
@@ -435,49 +344,4 @@ defmodule WandererApp.Api.Map do
public? false
end
end
# SSE Subscription Validation
#
# This validation ensures that SSE can only be enabled when:
# 1. SSE is being disabled (always allowed)
# 2. Map is being created (skip validation, will be checked on first update)
# 3. Community Edition mode (always allowed)
# 4. Enterprise mode with active subscription
defp validate_sse_subscription(changeset, _context) do
sse_enabled = Ash.Changeset.get_attribute(changeset, :sse_enabled)
map_id = changeset.data.id
subscriptions_enabled = WandererApp.Env.map_subscriptions_enabled?()
cond do
# Not enabling SSE - no validation needed
not sse_enabled ->
:ok
# Map creation (no ID yet) - skip validation
is_nil(map_id) ->
:ok
# Community Edition mode - always allow
not subscriptions_enabled ->
:ok
# Enterprise mode - check subscription
true ->
validate_active_subscription(map_id)
end
end
defp validate_active_subscription(map_id) do
case WandererApp.Map.is_subscription_active?(map_id) do
{:ok, true} ->
:ok
{:ok, false} ->
{:error, field: :sse_enabled, message: "Active subscription required to enable SSE"}
{:error, reason} ->
Logger.error("Error checking subscription status: #{inspect(reason)}")
{:error, field: :sse_enabled, message: "Unable to verify subscription status"}
end
end
end

View File

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

View File

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

View File

@@ -27,11 +27,6 @@ defmodule WandererApp.Api.MapCharacterSettings do
includes([:map, :character])
default_fields([
:tracked,
:followed
])
derive_filter?(true)
derive_sort?(true)
@@ -86,6 +81,12 @@ 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
@@ -133,8 +134,6 @@ defmodule WandererApp.Api.MapCharacterSettings do
require_atomic? false
accept([
:tracked,
:followed,
:ship,
:ship_name,
:ship_item_id,
@@ -146,7 +145,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :track do
accept [:map_id, :character_id]
require_atomic? false
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
@@ -159,7 +159,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :untrack do
accept [:map_id, :character_id]
require_atomic? false
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
@@ -172,7 +173,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :follow do
accept [:map_id, :character_id]
require_atomic? false
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
@@ -185,7 +187,8 @@ defmodule WandererApp.Api.MapCharacterSettings do
update :unfollow do
accept [:map_id, :character_id]
require_atomic? false
argument :map_id, :string, allow_nil?: false
argument :character_id, :uuid, allow_nil?: false
# Load the record first
load do
@@ -224,17 +227,14 @@ defmodule WandererApp.Api.MapCharacterSettings do
attribute :tracked, :boolean do
default false
public? true
allow_nil? true
end
attribute :followed, :boolean do
default false
public? true
allow_nil? true
end
# Note: These attributes are encrypted (AshCloak) and intentionally not public
attribute :solar_system_id, :integer
attribute :structure_id, :integer
attribute :station_id, :integer

View File

@@ -4,8 +4,7 @@ defmodule WandererApp.Api.MapConnection do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
extensions: [AshJsonApi.Resource]
postgres do
repo(WandererApp.Repo)
@@ -22,19 +21,6 @@ defmodule WandererApp.Api.MapConnection do
includes([:map])
default_fields([
:solar_system_source,
:solar_system_target,
:mass_status,
:time_status,
:ship_size_type,
:type,
:wormhole_type,
:count_of_passage,
:locked,
:custom_info
])
derive_filter?(true)
derive_sort?(true)
@@ -87,56 +73,7 @@ defmodule WandererApp.Api.MapConnection do
:custom_info
]
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
defaults [:create, :read, :update, :destroy]
read :read_by_map do
argument(:map_id, :string, allow_nil?: false)
@@ -173,57 +110,45 @@ defmodule WandererApp.Api.MapConnection do
update :update_mass_status do
accept [:mass_status]
require_atomic? false
end
update :update_time_status do
accept [:time_status]
require_atomic? false
end
update :update_ship_size_type do
accept [:ship_size_type]
require_atomic? false
end
update :update_locked do
accept [:locked]
require_atomic? false
end
update :update_custom_info do
accept [:custom_info]
require_atomic? false
end
update :update_type do
accept [:type]
require_atomic? false
end
update :update_wormhole_type do
accept [:wormhole_type]
require_atomic? false
end
end
attributes do
uuid_primary_key :id
attribute :solar_system_source, :integer do
public? true
end
attribute :solar_system_target, :integer do
public? true
end
attribute :solar_system_source, :integer
attribute :solar_system_target, :integer
# where 0 - greater than half
# where 1 - less than half
# where 2 - critical less than 10%
attribute :mass_status, :integer do
default(0)
public? true
allow_nil?(true)
end
@@ -236,7 +161,7 @@ defmodule WandererApp.Api.MapConnection do
# 6 - EOL 48h
attribute :time_status, :integer do
default(0)
public? true
allow_nil?(true)
end
@@ -247,7 +172,7 @@ defmodule WandererApp.Api.MapConnection do
# where 4 - Capital
attribute :ship_size_type, :integer do
default(2)
public? true
allow_nil?(true)
end
@@ -256,26 +181,21 @@ defmodule WandererApp.Api.MapConnection do
# where 2 - Bridge
attribute :type, :integer do
default(0)
public? true
allow_nil?(true)
end
attribute :wormhole_type, :string do
public? true
end
attribute :wormhole_type, :string
attribute :count_of_passage, :integer do
default(0)
public? true
allow_nil?(true)
end
attribute :locked, :boolean do
public? true
end
attribute :locked, :boolean
attribute :custom_info, :string do
public? true
allow_nil? true
end

View File

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

View File

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

View File

@@ -3,8 +3,7 @@ defmodule WandererApp.Api.MapPing do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
primary_read_warning?: false
data_layer: AshPostgres.DataLayer
postgres do
repo(WandererApp.Repo)
@@ -37,18 +36,7 @@ defmodule WandererApp.Api.MapPing do
:message
]
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
defaults [:read, :update, :destroy]
create :new do
accept [
@@ -60,6 +48,14 @@ 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
@@ -80,10 +76,6 @@ defmodule WandererApp.Api.MapPing do
filter(expr(inserted_at <= ^arg(:inserted_before)))
end
# Admin action for cleanup - no actor filtering
read :all_pings do
end
end
attributes do

View File

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

View File

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

View File

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

View File

@@ -18,15 +18,6 @@ defmodule WandererApp.Api.MapSubscription do
:map
])
default_fields([
:plan,
:status,
:characters_limit,
:hubs_limit,
:active_till,
:auto_renew?
])
# Enable automatic filtering and sorting
derive_filter?(true)
derive_sort?(true)
@@ -71,11 +62,7 @@ defmodule WandererApp.Api.MapSubscription do
:auto_renew?
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
read :all_active do
prepare build(sort: [updated_at: :asc], load: [:map])
@@ -101,39 +88,32 @@ 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
@@ -144,7 +124,6 @@ defmodule WandererApp.Api.MapSubscription do
attribute :plan, :atom do
default "alpha"
public? true
constraints(
one_of: [
@@ -160,7 +139,6 @@ defmodule WandererApp.Api.MapSubscription do
attribute :status, :atom do
default "active"
public? true
constraints(
one_of: [
@@ -175,24 +153,22 @@ defmodule WandererApp.Api.MapSubscription do
attribute :characters_limit, :integer do
default(100)
public? true
allow_nil?(true)
end
attribute :hubs_limit, :integer do
default(10)
public? true
allow_nil?(true)
end
attribute :active_till, :utc_datetime do
allow_nil? true
public? true
end
attribute :auto_renew?, :boolean do
allow_nil? false
public? true
end
create_timestamp(:inserted_at)

View File

@@ -24,12 +24,16 @@ defmodule WandererApp.Api.MapSystem do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
extensions: [AshJsonApi.Resource]
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
@@ -66,7 +70,10 @@ defmodule WandererApp.Api.MapSystem do
define(:upsert, action: :upsert)
define(:destroy, action: :destroy)
define :by_id, action: :get_by_id, args: [:id], get?: true
define(:by_id,
get_by: [:id],
action: :read
)
define(:by_solar_system_id,
get_by: [:solar_system_id],
@@ -96,7 +103,6 @@ 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)
@@ -122,56 +128,7 @@ defmodule WandererApp.Api.MapSystem do
:linked_sig_eve_id
]
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
defaults [:create, :update, :destroy]
create :upsert do
primary? false
@@ -201,9 +158,6 @@ 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,
@@ -211,11 +165,6 @@ 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)))
@@ -237,59 +186,44 @@ defmodule WandererApp.Api.MapSystem do
update :update_name do
accept [:name]
require_atomic? false
end
update :update_description do
accept [:description]
require_atomic? false
end
update :update_locked do
accept [:locked]
require_atomic? false
end
update :update_status do
accept [:status]
require_atomic? false
end
update :update_tag do
accept [:tag]
require_atomic? false
end
update :update_temporary_name do
accept [:temporary_name]
require_atomic? false
end
update :update_custom_name do
accept [:custom_name]
require_atomic? false
end
update :update_labels do
accept [:labels]
require_atomic? false
end
update :update_position do
accept [:position_x, :position_y]
require_atomic? false
change(set_attribute(:visible, true))
end
update :update_linked_sig_eve_id do
accept [:linked_sig_eve_id]
require_atomic? false
end
update :update_visible do
accept [:visible]
require_atomic? false
end
end

View File

@@ -19,10 +19,6 @@ defmodule WandererApp.Api.MapSystemComment do
:character
])
default_fields([
:text
])
routes do
base("/map_system_comments")
@@ -63,6 +59,12 @@ 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
@@ -77,7 +79,6 @@ defmodule WandererApp.Api.MapSystemComment do
attribute :text, :string do
allow_nil? false
public? true
end
create_timestamp(:inserted_at)

View File

@@ -16,20 +16,6 @@ defmodule WandererApp.Api.MapSystemSignature do
includes([:system])
default_fields([
:eve_id,
:character_eve_id,
:name,
:description,
:temporary_name,
:type,
:linked_system_id,
:kind,
:group,
:custom_info,
:deleted
])
derive_filter?(true)
derive_sort?(true)
@@ -125,6 +111,10 @@ 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
@@ -149,17 +139,14 @@ 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
@@ -198,56 +185,42 @@ defmodule WandererApp.Api.MapSystemSignature do
attribute :eve_id, :string do
allow_nil? false
public? true
end
attribute :character_eve_id, :string do
allow_nil? false
public? true
end
attribute :name, :string do
allow_nil? true
public? true
end
attribute :description, :string do
allow_nil? true
public? true
end
attribute :temporary_name, :string do
allow_nil? true
public? true
end
attribute :type, :string do
allow_nil? true
public? true
end
attribute :linked_system_id, :integer do
allow_nil? true
public? true
end
attribute :kind, :string do
public? true
end
attribute :group, :string do
public? true
end
attribute :kind, :string
attribute :group, :string
attribute :custom_info, :string do
allow_nil? true
public? true
end
attribute :deleted, :boolean do
allow_nil? false
default false
public? true
end
attribute :update_forced_at, :utc_datetime do

View File

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

View File

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

View File

@@ -24,13 +24,6 @@ defmodule WandererApp.Api.MapUserSettings do
:user
])
default_fields([
:settings,
:main_character_eve_id,
:following_character_eve_id,
:hubs
])
routes do
base("/map_user_settings")
@@ -60,30 +53,22 @@ defmodule WandererApp.Api.MapUserSettings do
:settings
]
defaults [:create, :read, :destroy]
update :update do
require_atomic? false
end
defaults [:create, :read, :update, :destroy]
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
@@ -92,22 +77,19 @@ defmodule WandererApp.Api.MapUserSettings do
attribute :settings, :string do
allow_nil? true
public? true
end
attribute :main_character_eve_id, :string do
allow_nil? true
public? true
end
attribute :following_character_eve_id, :string do
allow_nil? true
public? true
end
attribute :hubs, {:array, :string} do
allow_nil?(true)
public? true
default([])
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,7 @@ defmodule WandererApp.Api.UserActivity do
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource],
primary_read_warning?: false
extensions: [AshJsonApi.Resource]
require Ash.Expr
@@ -31,13 +30,6 @@ defmodule WandererApp.Api.UserActivity do
includes([:character, :user])
default_fields([
:entity_id,
:entity_type,
:event_type,
:event_data
])
derive_filter?(true)
derive_sort?(true)
@@ -63,8 +55,7 @@ defmodule WandererApp.Api.UserActivity do
:entity_type,
:event_type,
:event_data,
:user_id,
:character_id
:user_id
]
read :read do
@@ -79,8 +70,14 @@ defmodule WandererApp.Api.UserActivity do
end
create :new do
accept [:entity_id, :entity_type, :event_type, :event_data, :user_id, :character_id]
accept [:entity_id, :entity_type, :event_type, :event_data]
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
@@ -93,12 +90,10 @@ defmodule WandererApp.Api.UserActivity do
attribute :entity_id, :string do
allow_nil? false
public? true
end
attribute :entity_type, :atom do
default "map"
public? true
constraints(
one_of: [
@@ -113,7 +108,6 @@ defmodule WandererApp.Api.UserActivity do
attribute :event_type, :atom do
default "custom"
public? true
constraints(
one_of: [
@@ -163,9 +157,7 @@ defmodule WandererApp.Api.UserActivity do
allow_nil?(false)
end
attribute :event_data, :string do
public? true
end
attribute :event_data, :string
create_timestamp(:inserted_at)
update_timestamp(:updated_at)

View File

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

View File

@@ -153,16 +153,13 @@ defmodule WandererApp.Application do
:ok
end
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(true),
do: [
WandererApp.StartCorpWalletTrackerTask
]
defp maybe_start_corp_wallet_tracker(_), do: []
defp maybe_start_corp_wallet_tracker(_),
do: []
defp maybe_start_kills_services do
# Don't start kills services in test environment

View File

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

View File

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

View File

@@ -598,6 +598,9 @@ defmodule WandererApp.Character.Tracker do
{:error, :skipped}
end
_ ->
{:error, :skipped}
end
_ ->
@@ -731,14 +734,6 @@ defmodule WandererApp.Character.Tracker do
{:character_alliance, {character_id, character_update}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{alliance_id: nil})
end
@@ -777,14 +772,6 @@ defmodule WandererApp.Character.Tracker do
{:character_alliance, {character_id, character_update}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{alliance_id: alliance_id})
@@ -812,7 +799,7 @@ defmodule WandererApp.Character.Tracker do
corporation_id
|> WandererApp.Esi.get_corporation_info()
|> case do
{:ok, %{"name" => corporation_name, "ticker" => corporation_ticker}} ->
{:ok, %{"name" => corporation_name, "ticker" => corporation_ticker} = corporation_info} ->
{:ok, character} =
WandererApp.Character.get_character(character_id)
@@ -839,14 +826,6 @@ defmodule WandererApp.Character.Tracker do
}}}
)
# Broadcast permission update to trigger LiveView refresh
# This ensures users are kicked off maps they no longer have access to
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character.eve_id}",
:update_permissions
)
state
|> Map.merge(%{corporation_id: corporation_id})
@@ -1023,7 +1002,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}
%{map_id: map_id, track: true} = track_settings
) do
if not Enum.member?(active_maps, map_id) do
WandererApp.Cache.put(

View File

@@ -1,18 +1,5 @@
defmodule WandererApp.Character.TrackerManager.Impl do
@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
"""
@moduledoc false
require Logger
defstruct [
@@ -40,13 +27,6 @@ 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
@@ -58,12 +38,6 @@ 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)
@@ -79,9 +53,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
true
)
Logger.debug(fn ->
"[TrackerManager] Queuing character #{character_id} for tracking start"
end)
Logger.debug(fn -> "Add character to track_characters_queue: #{inspect(character_id)}" end)
WandererApp.Cache.insert_or_update(
"track_characters_queue",
@@ -99,33 +71,13 @@ 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 ->
"[TrackerManager] Stopping tracker for character #{character_id} - " <>
"reason: no active maps (garbage collected after #{div(@inactive_character_timeout, 60_000)} minutes)"
end)
Logger.debug(fn -> "Shutting down character tracker: #{inspect(character_id)}" 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, 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
:telemetry.execute([:wanderer_app, :character, :tracker, :stopped], %{count: 1})
end
WandererApp.Cache.insert_or_update(
@@ -149,35 +101,13 @@ 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)
case WandererApp.Character.Tracker.update_settings(character_id, track_settings) do
{:ok, character_state} ->
WandererApp.Character.update_character_state(character_id, character_state)
{:ok, character_state} =
WandererApp.Character.Tracker.update_settings(character_id, track_settings)
{: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
WandererApp.Character.update_character_state(character_id, character_state)
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
@@ -200,20 +130,8 @@ defmodule WandererApp.Character.TrackerManager.Impl do
"character_untrack_queue",
[],
fn untrack_queue ->
original_length = length(untrack_queue)
filtered =
untrack_queue
|> Enum.reject(fn {m_id, c_id} -> m_id == map_id and c_id == character_id end)
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
untrack_queue
|> Enum.reject(fn {m_id, c_id} -> m_id == map_id and c_id == character_id end)
end
)
end
@@ -252,12 +170,6 @@ 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, %{})
@@ -274,66 +186,35 @@ defmodule WandererApp.Character.TrackerManager.Impl do
{:ok, characters} = WandererApp.Cache.lookup("tracked_characters", [])
Logger.debug(fn ->
"[TrackerManager] Running garbage collection on #{length(characters)} tracked characters"
end)
characters
|> Task.async_stream(
fn character_id ->
case WandererApp.Cache.lookup("character:#{character_id}:last_active_time") do
{:ok, nil} ->
:skip
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)
{: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
:skip
{: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
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)
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)
_ ->
:ok
end
end)
state
@@ -345,22 +226,9 @@ defmodule WandererApp.Character.TrackerManager.Impl do
) do
Process.send_after(self(), :untrack_characters, @untrack_characters_interval)
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
WandererApp.Cache.lookup!("character_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")
@@ -387,36 +255,12 @@ 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 ->
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)
|> Enum.each(fn _result -> :ok end)
state
end
@@ -424,17 +268,9 @@ 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 ->
"[TrackerManager] Executing stop_track for character #{character_id}"
end)
Logger.debug(fn -> "Stopping character tracker: #{inspect(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
@@ -443,9 +279,7 @@ 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 ->
"[TrackerManager] Starting tracker for character #{character_id}"
end)
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
WandererApp.Cache.insert_or_update(
"tracked_characters",
@@ -478,30 +312,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
character_id,
%{opts: opts}
])
# Emit telemetry for tracker start
:telemetry.execute(
[:wanderer_app, :character, :tracker, :started],
%{count: 1, system_time: System.system_time()},
%{character_id: character_id}
)
else
true ->
Logger.debug(fn ->
"[TrackerManager] Character #{character_id} already being tracked"
end)
WandererApp.Cache.insert_or_update(
"track_characters_queue",
[],
fn existing ->
existing
|> Enum.reject(fn c_id -> c_id == character_id end)
end
)
WandererApp.Cache.delete("#{character_id}:track_requested")
_ ->
WandererApp.Cache.insert_or_update(
"track_characters_queue",

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ 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]
@@ -73,7 +74,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
@@ -100,6 +101,33 @@ 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}",
@@ -245,8 +273,6 @@ 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
@@ -322,7 +348,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}
@@ -332,7 +358,7 @@ defmodule WandererApp.Esi.ApiClient do
end
end
defp do_get_request(path, api_opts, opts, pool) do
defp do_get_request(path, api_opts \\ [], opts \\ [], pool \\ @general_pool) do
try do
req_options_for_pool(pool)
|> Req.new()
@@ -422,7 +448,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}} ->
{:ok, %{status: status, headers: headers}} ->
{:error, "Unexpected status: #{status}"}
{:error, %Mint.TransportError{reason: :timeout}} ->
@@ -806,10 +832,10 @@ defmodule WandererApp.Esi.ApiClient do
defp handle_refresh_token_result(
{:error, %OAuth2.Error{reason: :econnrefused} = error},
_character,
character,
character_id,
expires_at,
_scopes
scopes
) do
expires_at_datetime = DateTime.from_unix!(expires_at)
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)

View File

@@ -7,8 +7,7 @@ defmodule WandererApp.EveDataService do
alias WandererApp.Utils.JSONUtil
# @eve_db_dump_url "https://www.fuzzwork.co.uk/dump/latest"
@eve_db_dump_url "https://wanderer-industries.github.io/wanderer-assets/sde-files"
@eve_db_dump_url "https://www.fuzzwork.co.uk/dump/latest"
@dump_file_names [
"invGroups.csv",
@@ -394,6 +393,9 @@ 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 ->
@@ -411,12 +413,8 @@ defmodule WandererApp.EveDataService do
defp get_security(security) do
case security do
nil ->
{:ok, ""}
_ ->
{:ok,
String.to_float(security) |> get_true_security() |> :erlang.float_to_binary(decimals: 1)}
nil -> {:ok, ""}
_ -> {:ok, String.to_float(security) |> get_true_security() |> Float.to_string(decimals: 1)}
end
end
@@ -498,23 +496,23 @@ defmodule WandererApp.EveDataService do
do: {:ok, 10_100}
defp get_wormhole_class_id(systems, region_id, constellation_id, solar_system_id) do
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}
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
end
defp get_wormhole_class_id(_region, _constellation, solar_system)

View File

@@ -178,10 +178,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
# Get allowed fields based on struct type
defp get_allowed_fields(module) do
module_name = module |> Module.split() |> List.last()
@@ -196,6 +192,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
defp serialize_fields(fields, visited) do
Enum.reduce(fields, %{}, fn {k, v}, acc ->
if is_nil(v) do

View File

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

View File

@@ -155,23 +155,26 @@ 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)
case WandererApp.ExternalEvents.SseAccessControl.sse_allowed?(event.map_id) do
:ok ->
WandererApp.ExternalEvents.SseStreamManager.broadcast_event(event.map_id, event_json)
# 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)
:telemetry.execute(
[:wanderer_app, :external_events, :relay, :delivered],
%{count: 1},
%{map_id: event.map_id, event_type: event.type}
)
{:error, _reason} ->
:ok
end
# Emit delivered telemetry
:telemetry.execute(
[:wanderer_app, :external_events, :relay, :delivered],
%{count: 1},
%{map_id: event.map_id, event_type: event.type}
)
%{state | event_count: state.event_count + 1}
end

View File

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

View File

@@ -182,7 +182,7 @@ defmodule WandererApp.Kills.Client do
end
# Guard against duplicate disconnection events
def handle_info({:disconnected, _reason}, %{connected: false, connecting: false} = state) do
def handle_info({:disconnected, reason}, %{connected: false, connecting: false} = state) do
{:noreply, state}
end
@@ -566,7 +566,7 @@ defmodule WandererApp.Kills.Client do
end
end
defp check_health(%{socket_pid: pid}) do
defp check_health(%{socket_pid: pid} = state) do
if socket_alive?(pid) do
:healthy
else
@@ -590,6 +590,22 @@ 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 """
@@ -624,7 +640,7 @@ defmodule WandererApp.Kills.Client do
}
case GenSocketClient.join(transport, "killmails:lobby", join_params) do
{:ok, _response} ->
{:ok, response} ->
send(state.parent, {:connected, self()})
# Reset disconnected flag on successful connection
{:ok, %{state | disconnected: false}}

View File

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

View File

@@ -403,24 +403,10 @@ defmodule WandererApp.Kills.MessageHandler do
defp extract_field(_data, _field_names), do: nil
# 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
# Specific field extractors using the generic function
@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) ||
@@ -433,26 +419,30 @@ 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_nested_field(data, ["corporation_ticker", "corp_ticker"], "corporation", "ticker")
defp get_corp_ticker(data) when is_map(data) do
extract_field(data, ["corporation_ticker", "corp_ticker"])
end
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_nested_field(data, ["corporation_name", "corp_name"], "corporation", "name")
defp get_corp_name(data) when is_map(data) do
extract_field(data, ["corporation_name", "corp_name"])
end
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_nested_field(data, ["alliance_ticker"], "alliance", "ticker")
defp get_alliance_ticker(data) when is_map(data) do
extract_field(data, ["alliance_ticker"])
end
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_nested_field(data, ["alliance_name"], "alliance", "name")
defp get_alliance_name(data) when is_map(data) do
extract_field(data, ["alliance_name"])
end
defp get_alliance_name(_), do: nil

View File

@@ -12,7 +12,6 @@ defmodule WandererApp.Map do
defstruct map_id: nil,
name: nil,
scope: :none,
scopes: nil,
owner_id: nil,
characters: [],
systems: Map.new(),
@@ -23,18 +22,11 @@ defmodule WandererApp.Map do
characters_limit: nil,
hubs_limit: nil
def new(
%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs} =
input
) do
# Extract the new scopes array field if present (nil if not set)
scopes = Map.get(input, :scopes)
def new(%{id: map_id, name: name, scope: scope, owner_id: owner_id, acls: acls, hubs: hubs}) do
map =
struct!(__MODULE__,
map_id: map_id,
scope: scope,
scopes: scopes,
owner_id: owner_id,
name: name,
acls: acls,
@@ -185,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
@@ -213,7 +205,7 @@ defmodule WandererApp.Map do
characters_ids =
characters
|> Enum.map(fn %{character_id: char_id} -> char_id end)
|> Enum.map(fn %{id: char_id} -> char_id end)
# Filter out characters that already exist
new_character_ids =
@@ -323,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
@@ -334,7 +326,7 @@ defmodule WandererApp.Map do
|> get_map!()
end
def update_options!(%{map_id: map_id} = _map, options) do
def update_options!(%{map_id: map_id} = map, options) do
map_id
|> update_map(%{options: options})

View File

@@ -348,9 +348,9 @@ defmodule WandererApp.Map.CacheRTree do
[{x1_min, x1_max}, {y1_min, y1_max}] = box1
[{x2_min, x2_max}, {y2_min, y2_max}] = box2
# Boxes intersect if they overlap on both axes (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
# 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
x_overlap and y_overlap
end

View File

@@ -9,19 +9,17 @@ 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
@check_maps_queue_interval :timer.seconds(1)
@pings_cleanup_interval :timer.minutes(5)
@pings_cleanup_interval :timer.minutes(10)
@pings_expire_minutes 60
# Test-aware async task runner
defp safe_async_task(fun) do
if @environment == :test do
if Mix.env() == :test do
# In tests, run synchronously to avoid database ownership issues
try do
fun.()
@@ -99,7 +97,6 @@ defmodule WandererApp.Map.Manager do
def handle_info(:cleanup_pings, state) do
try do
cleanup_expired_pings()
cleanup_orphaned_pings()
{:noreply, state}
rescue
e ->
@@ -116,20 +113,11 @@ 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])
# 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
Server.Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: system.solar_system_id,
type: type
})
Ash.destroy!(ping)
end)
@@ -142,51 +130,6 @@ defmodule WandererApp.Map.Manager do
end
end
defp cleanup_orphaned_pings() do
case WandererApp.MapPingsRepo.get_orphaned_pings() do
{:ok, []} ->
:ok
{:ok, orphaned_pings} ->
Logger.info(
"[cleanup_orphaned_pings] Found #{length(orphaned_pings)} orphaned pings, cleaning up..."
)
Enum.each(orphaned_pings, fn %{id: ping_id, map_id: map_id, type: type, system: system} = ping ->
reason =
cond do
is_nil(ping.system) -> "system deleted"
is_nil(ping.character) -> "character deleted"
is_nil(ping.map) -> "map deleted"
not is_nil(system) and system.visible == false -> "system hidden (visible=false)"
true -> "unknown"
end
Logger.warning(
"[cleanup_orphaned_pings] Destroying orphaned ping #{ping_id} (map_id: #{map_id}, reason: #{reason})"
)
# Broadcast cancellation if map_id is still valid
if map_id do
Server.Impl.broadcast!(map_id, :ping_cancelled, %{
id: ping_id,
solar_system_id: nil,
type: type
})
end
Ash.destroy!(ping)
end)
Logger.info("[cleanup_orphaned_pings] Cleaned up #{length(orphaned_pings)} orphaned pings")
:ok
{:error, error} ->
Logger.error("Failed to fetch orphaned pings: #{inspect(error)}")
{:error, error}
end
end
defp start_maps() do
chunks =
@maps_queue
@@ -196,7 +139,7 @@ defmodule WandererApp.Map.Manager do
WandererApp.Queue.clear(@maps_queue)
if @environment == :test do
if Mix.env() == :test do
# In tests, run synchronously to avoid database ownership issues
Logger.debug(fn -> "Starting maps synchronously in test mode" end)

View File

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

View File

@@ -18,22 +18,10 @@ defmodule WandererApp.Map.MapPool do
@map_pool_limit 10
@garbage_collection_interval :timer.hours(4)
# 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)
@systems_cleanup_timeout :timer.minutes(30)
@characters_cleanup_timeout :timer.minutes(5)
@connections_cleanup_timeout :timer.minutes(5)
@backup_state_timeout :timer.minutes(1)
def new(), do: __struct__()
def new(args), do: __struct__(args)
@@ -199,7 +187,7 @@ defmodule WandererApp.Map.MapPool do
# Schedule periodic tasks
Process.send_after(self(), :backup_state, @backup_state_timeout)
Process.send_after(self(), :cleanup_systems, @systems_cleanup_timeout)
Process.send_after(self(), :cleanup_systems, 15_000)
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)
@@ -329,9 +317,6 @@ 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
@@ -347,6 +332,8 @@ 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
@@ -364,9 +351,13 @@ 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]}
@@ -442,6 +433,8 @@ 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
@@ -459,10 +452,14 @@ 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)}
@@ -551,6 +548,9 @@ defmodule WandererApp.Map.MapPool do
# and the cleanup operations are safe to leave in a "stopped" state
end
@impl true
def handle_call(:error, _, state), do: {:stop, :error, :ok, state}
@impl true
def handle_info(:backup_state, %{map_ids: map_ids, uuid: uuid} = state) do
Process.send_after(self(), :backup_state, @backup_state_timeout)

View File

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

View File

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

View File

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

View File

@@ -56,8 +56,6 @@ 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

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