diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d2fcdeb6e..cf7628b5b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,30 +1,119 @@ -FROM elixir:1.17-otp-27 +FROM elixir:1.17-otp-26 -RUN apt install -yq curl gnupg -# Install OS packages and Node.js (via nodesource), -# plus inotify-tools and yarn +ARG USERNAME=developer +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +ENV DEBIAN_FRONTEND=noninteractive + +# Install OS packages, Node.js (via nodesource), yarn, and gh CLI RUN apt-get update && apt-get install -y --no-install-recommends \ - sudo \ - curl \ - make \ - git \ - bash \ - build-essential \ - ca-certificates \ - jq \ - vim \ - net-tools \ - procps \ - netcat-traditional \ - # Optionally add any other tools you need, e.g. vim, wget... + sudo \ + curl \ + make \ + git \ + git-lfs \ + bash \ + zsh \ + build-essential \ + ca-certificates \ + gnupg \ + jq \ + ripgrep \ + tree \ + lsof \ + htop \ + less \ + vim \ + locales \ + net-tools \ + netcat-openbsd \ + procps \ + inotify-tools \ && curl -sL https://deb.nodesource.com/setup_18.x | bash - \ - && apt-get install -y --no-install-recommends nodejs inotify-tools \ + && apt-get install -y --no-install-recommends nodejs \ && npm install -g yarn \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN apt --fix-broken install +# Generate UTF-8 locale (some Elixir libs are encoding-sensitive) +RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && locale-gen +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +# Create non-root user with passwordless sudo and zsh as login shell +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID -m -s /bin/zsh $USERNAME \ + && echo "$USERNAME ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +# Persistent shell history across container rebuilds +RUN mkdir -p /commandhistory && chown -R $USERNAME:$USERNAME /commandhistory + +# Allow git operations on the bind-mounted workspace +RUN git config --system --add safe.directory /app -RUN mix local.hex --force +USER $USERNAME + +RUN mix local.hex --force && mix local.rebar --force + +# Install oh-my-zsh +RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended + +# Install zsh plugins (autosuggestions + syntax highlighting) +RUN git clone https://github.com/zsh-users/zsh-autosuggestions \ + ${ZSH_CUSTOM:-/home/$USERNAME/.oh-my-zsh/custom}/plugins/zsh-autosuggestions \ + && git clone https://github.com/zsh-users/zsh-syntax-highlighting \ + ${ZSH_CUSTOM:-/home/$USERNAME/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting + +# .zshrc with oh-my-zsh, persistent history, project aliases, and dotfiles +RUN printf '%s\n' \ + 'export ZSH="$HOME/.oh-my-zsh"' \ + 'ZSH_THEME="robbyrussell"' \ + 'plugins=(git mix docker docker-compose zsh-autosuggestions zsh-syntax-highlighting)' \ + 'source $ZSH/oh-my-zsh.sh' \ + '' \ + '# Persistent history (mounted volume)' \ + 'HISTFILE=/commandhistory/.zsh_history' \ + 'HISTSIZE=10000' \ + 'SAVEHIST=10000' \ + 'setopt appendhistory' \ + 'setopt sharehistory' \ + 'setopt hist_ignore_dups' \ + '' \ + '# NOTE: personal dotfiles are intentionally NOT sourced here. Doing so' \ + '# only ever worked for a developer who separately bind-mounts ~/.dotfiles' \ + '# via a local (gitignored) compose override, so it was dead code for' \ + '# everyone else. That wiring now lives with the mount that enables it.' \ + '' \ + '# Aliases' \ + 'alias ll="ls -la"' \ + 'alias la="ls -A"' \ + 'alias gs="git status"' \ + 'alias gd="git diff"' \ + '' \ + '# Elixir/Phoenix aliases' \ + 'alias mt="mix test"' \ + 'alias mc="mix compile"' \ + 'alias mf="mix format"' \ + 'alias ms="iex -S mix phx.server"' \ + 'alias deps="mix deps.get"' \ + '' \ + '# Ensure Claude Code and local binaries are on PATH' \ + 'export PATH="$HOME/.local/bin:$PATH"' \ + '' \ + 'export EDITOR=vim' \ + 'cd /app 2>/dev/null || true' \ + > /home/$USERNAME/.zshrc WORKDIR /app + +ENV DEBIAN_FRONTEND=dialog diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 000000000..b6f196a72 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.9", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 35045da48..fee38a00f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,30 +1,57 @@ { "name": "wanderer-dev", - "dockerComposeFile": ["./docker-compose.yml"], + "dockerComposeFile": [ + "./docker-compose.yml", + "./docker-compose.override.yml" + ], + "service": "wanderer", + "workspaceFolder": "/app", + "shutdownAction": "stopCompose", + "remoteUser": "developer", + "containerUser": "developer", + + "initializeCommand": "test -f .devcontainer/docker-compose.override.yml || echo 'services: {}' > .devcontainer/docker-compose.override.yml", + "postCreateCommand": "bash .devcontainer/setup.sh", + "postStartCommand": "bash .devcontainer/post-start.sh", + + "remoteEnv": { + "PATH": "${containerEnv:HOME}/.local/bin:${containerEnv:PATH}" + }, "customizations": { "vscode": { "extensions": [ - "jakebecker.elixir-ls", "JakeBecker.elixir-ls", + "phoenixframework.phoenix", + "pantajoe.vscode-elixir-credo", "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode" + "esbenp.prettier-vscode", + "eamodio.gitlens", + "mikestead.dotenv", + "ms-azuretools.vscode-docker" ], "settings": { "editor.formatOnSave": true, "search.exclude": { - "**/doc": true + "**/doc": true, + "**/_build": true, + "**/deps": true }, - "elixirLS.dialyzerEnabled": false + "elixirLS.dialyzerEnabled": false, + "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/bin/zsh" + } + } } } }, - "service": "wanderer", - "workspaceFolder": "/app", - "shutdownAction": "stopCompose", - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "networkArgs": ["--add-host=host.docker.internal:host-gateway"] + + "forwardPorts": [4444], + "portsAttributes": { + "4444": { + "label": "Wanderer (Phoenix)", + "onAutoForward": "notify" } - }, - "forwardPorts": [4444] + } } diff --git a/.devcontainer/docker-compose.override.yml.example b/.devcontainer/docker-compose.override.yml.example new file mode 100644 index 000000000..767261f5a --- /dev/null +++ b/.devcontainer/docker-compose.override.yml.example @@ -0,0 +1,18 @@ +# docker-compose.override.yml.example +# +# Copy this file to docker-compose.override.yml for host-specific mounts. +# The override file is gitignored and will be auto-created (empty) by +# devcontainer.json's initializeCommand if missing. +# +# Uncomment any sections you want. +services: + wanderer: + volumes: + # Host SSH keys (Git over SSH, signing) + - ~/.ssh:/home/developer/.ssh:ro + + # GitHub CLI auth + - ~/.config/gh:/home/developer/.config/gh:ro + + # Claude Code config, commands, plugins, settings + - ~/.claude:/home/developer/.claude:cached diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 7ba1668b2..4c841b761 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,36 +1,55 @@ -version: "0.1" - services: db: - image: postgres:13-alpine + # Matches production (postgres:16-alpine). AshPostgres declares + # min_pg_version 15 in lib/wanderer_app/repo.ex, so 13 was below the + # supported floor. The volume is renamed from db-new because a PG13 data + # directory is not readable by a PG16 server โ€” reusing the old name would + # make the container fail to start rather than upgrade. Recreate the + # databases with `mix ecto.setup`, which also runs priv/repo/seeds.exs to + # re-download the EVE SDE reference data (solar systems, jumps, ship types). + image: postgres:16-alpine restart: always environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - - "5432:5432" + - "5436:5432" volumes: - - db-new:/var/lib/postgresql/data + - db-pg16:/var/lib/postgresql/data wanderer: + build: + context: . + dockerfile: Dockerfile environment: PORT: 4444 DB_HOST: db WEB_APP_URL: "http://localhost:4444" ERL_AFLAGS: "-kernel shell_history enabled" - build: - context: . - dockerfile: Dockerfile ports: - 4444:4444 + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ..:/app:delegated - - ~/.gitconfig:/root/.gitconfig - - ~/.gitignore:/root/.gitignore - - ~/.ssh:/root/.ssh - - elixir-artifacts:/opt/elixir-artifacts + - command-history:/commandhistory + # deps/ and _build/ deliberately live in the repo via the /app bind + # rather than in named volumes nested at /app/deps and /app/_build. + # + # A named volume mounted inside the bind MASKS whatever the repo has at + # that path, and it only does so for the main working tree. Git worktrees + # (this repo keeps them under .claude/worktrees/) therefore came up with + # an empty deps/ and _build/ and had to recompile from scratch, which is + # what drove the ad-hoc `ln -sfn /app/deps deps` workarounds โ€” those + # produce symlinks that resolve in the container but dangle on the host. + # + # Tradeoff accepted: host and container now share _build. BEAM artifacts + # are OTP-version-specific, so after changing the Erlang/Elixir version + # (including this container's OTP 27 -> 26 correction), run + # `rm -rf _build` once or mix will report modules compiled by a + # different version. command: sleep infinity volumes: - elixir-artifacts: {} - db-new: {} + db-pg16: {} + command-history: {} diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100755 index 000000000..38ddcbdcb --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Post-start script - runs every time the container starts + +set -e + +echo "๐Ÿ”„ Running post-start tasks..." + +CONTAINER_HOME="$(eval echo ~)" + +# Display helpful information +echo "" +echo "๐Ÿ“Š Environment Info:" +echo " Elixir version: $(elixir --version | tail -1 | awk '{print $2}' 2>/dev/null || echo 'not installed')" +echo " Node version: $(node --version 2>/dev/null || echo 'not installed')" +echo " Claude Code: $(claude --version 2>/dev/null || echo 'not installed')" +echo " Shell: $(basename "${SHELL}")" +echo " Working dir: $(pwd)" +echo "" + +# Check database (Postgres) +DB_HOST=${DB_HOST:-db} +if command -v nc >/dev/null 2>&1; then + if nc -z "$DB_HOST" 5432 2>/dev/null; then + echo "๐Ÿ’พ Postgres: ready (${DB_HOST}:5432)" + else + echo "โš ๏ธ Postgres not yet ready at ${DB_HOST}:5432 โ€” check docker compose logs db" + fi +fi + +echo "" +echo "๐ŸŽฏ Ready to code!" +echo "" +echo "Useful commands:" +echo " make server # Start Phoenix dev server (port 4444)" +echo " mix test # Run tests" +echo " mix format # Format code" +echo " claude # Start Claude Code CLI" +echo "" diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index a63b8dcf4..b3a6da0dd 100755 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash set -e +echo "โ†’ ensuring build dirs are writable" +# deps/ and _build/ come from the /app bind mount (see docker-compose.yml), so +# they carry host ownership. When the host uid differs from the container user's, +# mix cannot write to them. Best-effort fix; harmless when uids already match. +sudo chown -R "$(id -u):$(id -g)" /app/deps /app/_build 2>/dev/null || true + echo "โ†’ fetching & compiling deps" mix deps.get mix compile @@ -8,7 +14,7 @@ mix compile # only run Ecto if the project actually has those tasks if mix help | grep -q "ecto.create"; then echo "โ†’ waiting for database to be ready..." - + # Wait for database to be ready DB_HOST=${DB_HOST:-db} timeout=60 @@ -21,19 +27,49 @@ if mix help | grep -q "ecto.create"; then sleep 1 timeout=$((timeout - 1)) done - + # Give the database a bit more time to fully initialize echo "โ†’ giving database 2 more seconds to fully initialize..." sleep 2 - + echo "โ†’ database is ready, running ecto.create && ecto.migrate" mix ecto.create --quiet mix ecto.migrate + + # Seed the EVE SDE reference data (solar systems, jumps, ship types) only when + # it is missing. `mix ecto.setup` would run priv/repo/seeds.exs unconditionally, + # but that downloads and bulk-imports the SDE (~23k rows) every time โ€” slow and + # pointless when the database volume already has it. Checking the table is the + # cheap way to tell a fresh volume from a warm one. + # + # --no-start matters: a bare `mix run` boots the whole supervision tree + # (TheraDataFetcher, TurnurDataFetcher, Map.Reconciler, TrackerManager, ...), + # which starts outbound pollers just to count rows and would report "not + # seeded" if any of them failed to start. Start ecto_sql and the Repo alone + # instead โ€” --no-start also skips :db_connection, so ensure_all_started is + # required or Repo.start_link exits with "no process". + echo "โ†’ checking EVE SDE reference data" + if mix run --no-start -e ' + {:ok, _} = Application.ensure_all_started(:ecto_sql) + {:ok, _} = WandererApp.Repo.start_link(pool_size: 2) + count = + case Ecto.Adapters.SQL.query(WandererApp.Repo, "select count(*) from map_solar_system_v2", []) do + {:ok, %{rows: [[n]]}} -> n + _ -> 0 + end + System.halt(if count > 0, do: 0, else: 1)' >/dev/null 2>&1; then + echo "โ†’ SDE data already present, skipping seeds" + else + echo "โ†’ seeding EVE SDE data (downloads the SDE, may take a few minutes)" + mix run priv/repo/seeds.exs || echo "โš ๏ธ SDE seeding failed โ€” run 'mix run priv/repo/seeds.exs' manually" + fi fi - cd assets - echo "โ†’ installing JS & CSS dependencies" - yarn install --frozen-lockfile - echo "โ†’ building assets" +echo "โ†’ installing JS & CSS dependencies" +cd assets +yarn install --frozen-lockfile + +echo "โ†’ building assets" +yarn build echo "โœ… setup complete" diff --git a/.dockerignore b/.dockerignore index 3477d38e4..0670829f8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,6 +30,7 @@ /test/ /tmp/ .elixir_ls +.devcontainer/ # Mix artifacts *.ez diff --git a/.env.example b/.env.example index 42b282ac8..65493946a 100644 --- a/.env.example +++ b/.env.example @@ -12,12 +12,61 @@ export WANDERER_PUBLIC_API_DISABLED="false" export WANDERER_CHARACTER_API_DISABLED="false" export WANDERER_KILLS_SERVICE_ENABLED="true" export WANDERER_KILLS_BASE_URL="ws://host.docker.internal:4004" +# Use IPv6 for the outbound WebSocket connection to wanderer-kills (optional, default false). +# Set to true only on Fly.io, whose private network (6PN) is IPv6-only. +# Leave unset everywhere else - on a host with no AAAA record the connection +# fails with :nxdomain and the client retries on its normal backoff. +# export WANDERER_KILLS_IPV6="true" export WANDERER_SSE_ENABLED="true" export WANDERER_WEBHOOKS_ENABLED="true" export WANDERER_SSE_MAX_CONNECTIONS="1000" export WANDERER_WEBHOOK_TIMEOUT_MS="15000" +# Killmails older than this many seconds are dropped before reaching Discord. +# Guards against an upstream replay burst on reconnect flooding the channel. +# (optional, default 3600) +# export WANDERER_DISCORD_MAX_KILLMAIL_AGE_SECONDS="3600" +# After the in-memory Discord dedup marks are lost, the tighter age limit below +# applies for this many seconds, so an upstream replay of already-posted kills +# is dropped for being old. 0 disables the window. (optional, default 600) +# export WANDERER_DISCORD_STARTUP_GRACE_SECONDS="600" +# Maximum killmail age while that window is armed. (optional, default 120) +# export WANDERER_DISCORD_STARTUP_MAX_KILLMAIL_AGE_SECONDS="120" +# Connection pool size for Discord kill notification delivery (optional, default 10) +# export WANDERER_DISCORD_POOL_SIZE="10" +# Adds a "Notable Items" section listing the most valuable DROPPED loot on each +# kill. Off by default: it costs an extra ESI killmail fetch per kill plus a +# market lookup, on the dispatcher's critical path. (optional, default false) +# export WANDERER_NOTABLE_ITEMS_ENABLED="false" +# Only items worth strictly more than this are listed (optional, default 50000000) +# export WANDERER_NOTABLE_ITEMS_THRESHOLD_ISK="50000000" +# At most this many items per kill, highest value first (optional, default 5) +# export WANDERER_NOTABLE_ITEMS_LIMIT="5" +# How long enrichment may hold up one kill batch. Raising it delays kill +# notifications for every map on the instance. (optional, default 1500) +# export WANDERER_NOTABLE_ITEMS_TIMEOUT_MS="1500" + +# How long corporation-ticker lookups may hold up one kill batch. +# (optional, default 1500) +# export WANDERER_CORP_TICKERS_TIMEOUT_MS="1500" + +# Incident switch for corporation-ticker lookups. On by default: turning it off +# means kill embeds lose the (TICKER) after each pilot name. +# (optional, default true) +# export WANDERER_CORP_TICKERS_ENABLED="true" + +# Incident switch for Discord mentions (role and user pings on kill and route +# notifications). On by default; turning it off silences every mention on the +# instance without touching per-map configuration. +# (optional, default true) +# export WANDERER_DISCORD_MENTIONS_ENABLED="true" # 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" + +# Voice-participant mentions on Discord kill notifications (optional). +# Both must be set; the bot must be invited to the guild (no permissions +# needed beyond guild visibility). +# export DISCORD_BOT_TOKEN= +# export DISCORD_GUILD_ID= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac9f87ab2..b0ea625b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,25 +2,69 @@ name: ๐Ÿงช Test Suite on: pull_request: - branches: [main, develop] + branches: [guarzo/zoo, main, develop] push: - branches: [main, develop] + branches: [guarzo/zoo, main, develop] permissions: contents: read pull-requests: write issues: write +# A newer push to the same ref makes the in-flight run irrelevant. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: MIX_ENV: test - ELIXIR_VERSION: '1.16' - OTP_VERSION: '26' - NODE_VERSION: '18' + ELIXIR_VERSION: '1.17.3' + OTP_VERSION: '26.2.5.5' + # Test shard count. Must match the `partition` matrix below; config/test.exs + # already suffixes the database name with MIX_TEST_PARTITION. + PARTITIONS: 4 jobs: - test: - name: Test Suite + # Compiles deps once and seeds the shared cache so the fan-out below doesn't + # each pay `mix deps.compile`. On a cache hit this is just a restore. + setup: + name: Setup runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + # The OTP/Elixir versions are part of the key: a _build compiled by a + # different OTP is not reusable, and the old `Linux-mix-` restore-key + # could match another workflow's OTP 27 cache. + - uses: actions/cache@v4 + id: deps-cache + with: + path: | + deps + _build + key: mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + + - name: Install and compile dependencies + if: steps.deps-cache.outputs.cache-hit != 'true' + run: | + mix deps.get + mix deps.compile + + tests: + name: Tests (${{ matrix.partition }}) + runs-on: ubuntu-latest + needs: setup + + strategy: + # One shard failing shouldn't hide failures in the others. + fail-fast: false + matrix: + partition: [1, 2, 3, 4] services: postgres: @@ -36,234 +80,333 @@ jobs: ports: - 5432:5432 + env: + MIX_TEST_PARTITION: ${{ matrix.partition }} + steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Elixir/OTP - uses: erlef/setup-beam@v1 + - uses: erlef/setup-beam@v1 with: elixir-version: ${{ env.ELIXIR_VERSION }} otp-version: ${{ env.OTP_VERSION }} - - name: Cache Elixir dependencies - uses: actions/cache@v3 + - uses: actions/cache/restore@v4 + id: deps-cache with: path: | deps _build - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- + key: mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} - - name: Install Elixir dependencies + # setup seeds this cache, but a restore can still miss (eviction, a failed + # save, a concurrent lockfile change). Without this the job dies on a + # confusing "module is not available" instead of just rebuilding. + - name: Install dependencies (cache miss) + if: steps.deps-cache.outputs.cache-hit != 'true' run: | mix deps.get mix deps.compile - - name: Check code formatting - id: format - run: | - if mix format --check-formatted; then - echo "status=โœ… Passed" >> $GITHUB_OUTPUT - echo "count=0" >> $GITHUB_OUTPUT - else - echo "status=โŒ Failed" >> $GITHUB_OUTPUT - 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<> $GITHUB_OUTPUT - echo "$output" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - continue-on-error: true + - name: Compile + run: mix compile - name: Setup database run: | mix ecto.create mix ecto.migrate - - name: Run tests with coverage - id: tests + # Output streams to the log instead of being captured into a file that + # nothing ever reads, and a real failure exits non-zero. + - name: Run tests + run: mix test --partitions ${{ env.PARTITIONS }} + + # Two migration files defining the same module is not a compile error: the + # later definition replaces the earlier one, so a migration silently never + # runs and the schema drift only surfaces much later. That happened once + # already (#122). Pure shell with no BEAM or deps, so it reports in seconds + # instead of waiting on `setup`. + migrations: + name: Migration hygiene + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Check for duplicate migration module names 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 - test_status="success" - else - echo "status=โŒ Some Failed" >> $GITHUB_OUTPUT - test_status="failed" + dupes=$(grep -h '^defmodule' priv/repo/migrations/*.exs \ + | awk '{print $2}' | sort | uniq -d) + if [ -n "$dupes" ]; then + echo "::error::Duplicate migration module names found" + printf '%s\n' "$dupes" | while read -r mod; do + echo "$mod is defined in:" + grep -lF "defmodule $mod do" priv/repo/migrations/*.exs | sed 's/^/ /' + done + exit 1 fi + echo "No duplicate migration module names." - # 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") + static: + name: Static analysis + runs-on: ubuntu-latest + needs: setup + outputs: + warnings: ${{ steps.compile.outputs.warnings }} + credo: ${{ steps.credo.outputs.total_issues }} - echo "total=$total_tests" >> $GITHUB_OUTPUT - echo "failures=$failures" >> $GITHUB_OUTPUT - echo "passed=$((total_tests - failures))" >> $GITHUB_OUTPUT + steps: + - uses: actions/checkout@v4 - # Calculate success rate - if [ "$total_tests" -gt 0 ]; then - success_rate=$(echo "scale=1; ($total_tests - $failures) * 100 / $total_tests" | bc) - else - success_rate="0" - fi - echo "success_rate=$success_rate" >> $GITHUB_OUTPUT + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} - exit_code=$? - echo "exit_code=$exit_code" >> $GITHUB_OUTPUT - continue-on-error: true + - uses: actions/cache/restore@v4 + id: deps-cache + with: + path: | + deps + _build + key: mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} - - name: Generate coverage report - id: coverage + # setup seeds this cache, but a restore can still miss (eviction, a failed + # save, a concurrent lockfile change). Without this the job dies on a + # confusing "module is not available" instead of just rebuilding. + - name: Install dependencies (cache miss) + if: steps.deps-cache.outputs.cache-hit != 'true' 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 + mix deps.get + mix deps.compile - # Determine status - if (( $(echo "$coverage >= 80" | bc -l) )); then - echo "status=โœ… Excellent" >> $GITHUB_OUTPUT - elif (( $(echo "$coverage >= 60" | bc -l) )); then - echo "status=โš ๏ธ Good" >> $GITHUB_OUTPUT - else - echo "status=โŒ Needs Improvement" >> $GITHUB_OUTPUT - fi - continue-on-error: true + # Gating: unformatted code fails the build. + - name: Check formatting + run: mix format --check-formatted - - name: Run Credo analysis + # Advisory on warnings, but a genuine compile error still fails the job. + - name: Compile and count warnings + id: compile + run: | + set +e + set -o pipefail + mix compile --force 2>&1 | tee compile_output.txt + status=$? + warnings=$(grep -c "warning:" compile_output.txt || true) + echo "warnings=${warnings:-0}" >> "$GITHUB_OUTPUT" + exit $status + + # Gating, like formatting above. Runs after the compile step so a genuine + # compile error reports as a compile error rather than as codegen noise. + # Catches resources that have drifted from priv/resource_snapshots โ€” + # typically after a dependency bump changes how a default is serialized, + # which is otherwise invisible until someone runs `mix ash.codegen` + # locally and gets an unrelated diff mixed into their feature branch. + - name: Check Ash codegen is up to date + run: mix ash.codegen --check + + - name: Run Credo id: credo + continue-on-error: true 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") - high_issues=$(echo "$output" | jq '.issues | map(select(.priority == "high")) | length' 2>/dev/null || echo "0") - normal_issues=$(echo "$output" | jq '.issues | map(select(.priority == "normal")) | length' 2>/dev/null || echo "0") - low_issues=$(echo "$output" | jq '.issues | map(select(.priority == "low")) | length' 2>/dev/null || echo "0") + # stderr goes to its own file: merging it into the JSON breaks the + # parse below and silently reports "unknown". + mix credo --strict --format=json > credo_output.json 2> credo_stderr.txt || true + if jq -e . credo_output.json > /dev/null 2>&1; then + issues=$(jq '.issues | length' credo_output.json) else - # Fallback: try to count issues from regular output - regular_output=$(mix credo --strict 2>&1 || true) - issues=$(echo "$regular_output" | grep -c "โ”ƒ" || echo "0") - high_issues="0" - normal_issues="0" - low_issues="0" + issues="unknown" fi + echo "total_issues=$issues" >> "$GITHUB_OUTPUT" - 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 + - name: Upload static analysis output + if: always() + uses: actions/upload-artifact@v4 + with: + name: static-analysis + path: | + compile_output.txt + credo_output.json + credo_stderr.txt + retention-days: 7 + if-no-files-found: ignore + + # Advisory, and deliberately not a dependency of the gate. The PLT is cached + # separately because mix.exs puts it in priv/plts, which the deps cache above + # does not cover -- that omission was rebuilding it from scratch every run. + dialyzer: + name: Dialyzer + runs-on: ubuntu-latest + needs: setup + outputs: + warnings: ${{ steps.dialyzer.outputs.warnings }} - # Determine status - if [ "$issues" -eq 0 ]; then - echo "status=โœ… Clean" >> $GITHUB_OUTPUT - elif [ "$issues" -lt 10 ]; then - echo "status=โš ๏ธ Minor Issues" >> $GITHUB_OUTPUT - else - echo "status=โŒ Needs Attention" >> $GITHUB_OUTPUT - fi - continue-on-error: true + steps: + - uses: actions/checkout@v4 - - name: Run Dialyzer analysis - id: dialyzer + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - uses: actions/cache/restore@v4 + id: deps-cache + with: + path: | + deps + _build + key: mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + + # setup seeds this cache, but a restore can still miss (eviction, a failed + # save, a concurrent lockfile change). Without this the job dies on a + # confusing "module is not available" instead of just rebuilding. + - name: Install dependencies (cache miss) + if: steps.deps-cache.outputs.cache-hit != 'true' + run: | + mix deps.get + mix deps.compile + + # restore-keys lets a mix.lock change reuse the previous PLT and update it + # incrementally, instead of paying the ~6m full rebuild on every bump. + - uses: actions/cache@v4 + with: + path: priv/plts + key: plt-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + plt-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}- + + - name: Build PLT run: | - # Ensure PLT is built + mkdir -p priv/plts mix dialyzer --plt - # Run Dialyzer and capture output - output=$(mix dialyzer --format=github 2>&1 || true) - echo "$output" > dialyzer_output.txt + - name: Run Dialyzer + id: dialyzer + continue-on-error: true + run: | + # --format=github emits GitHub annotations ("::warning file=..."), + # never the literal "warning:", so anchor the count to that. + mix dialyzer --format=github 2>&1 | tee dialyzer_output.txt || true + warnings=$(grep -c "^::warning" dialyzer_output.txt || true) + echo "warnings=${warnings:-0}" >> "$GITHUB_OUTPUT" + + - name: Upload Dialyzer output + if: always() + uses: actions/upload-artifact@v4 + with: + name: dialyzer + path: dialyzer_output.txt + retention-days: 7 + if-no-files-found: ignore + + # Unsharded, push-only, and off the PR critical path. Uses local excoveralls + # rather than coveralls.github: no coveralls.io repo is configured for this + # fork, which is why the old PR comment always reported 0%. + coverage: + name: Coverage + runs-on: ubuntu-latest + needs: setup + if: github.event_name == 'push' + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: wanderer_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 - # Count warnings and errors - warnings=$(echo "$output" | grep -c "warning:" || echo "0") - errors=$(echo "$output" | grep -c "error:" || echo "0") + steps: + - uses: actions/checkout@v4 - echo "warnings=$warnings" >> $GITHUB_OUTPUT - echo "errors=$errors" >> $GITHUB_OUTPUT + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} - # Determine status - if [ "$errors" -eq 0 ] && [ "$warnings" -eq 0 ]; then - echo "status=โœ… Clean" >> $GITHUB_OUTPUT - elif [ "$errors" -eq 0 ]; then - echo "status=โš ๏ธ Warnings Only" >> $GITHUB_OUTPUT - else - echo "status=โŒ Has Errors" >> $GITHUB_OUTPUT - fi + - uses: actions/cache/restore@v4 + id: deps-cache + with: + path: | + deps + _build + key: mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + + # setup seeds this cache, but a restore can still miss (eviction, a failed + # save, a concurrent lockfile change). Without this the job dies on a + # confusing "module is not available" instead of just rebuilding. + - name: Install dependencies (cache miss) + if: steps.deps-cache.outputs.cache-hit != 'true' + run: | + mix deps.get + mix deps.compile + + - name: Setup database + run: | + mix ecto.create + mix ecto.migrate + + - name: Report coverage continue-on-error: true + run: | + mix coveralls 2>&1 | tee coverage_output.txt || true + { + echo "### ๐Ÿ“Š Coverage" + echo "" + echo '```' + tail -n 20 coverage_output.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # Named "Test Suite" because that is the context the guarzo/zoo ruleset + # requires. Renaming this job silently blocks every PR on a check that no + # longer reports. + gate: + name: Test Suite + runs-on: ubuntu-latest + needs: [tests, static, migrations] + if: always() - - name: Create test results summary - id: summary + steps: + - name: Verify required jobs succeeded run: | - # Calculate overall score - format_score=${{ steps.format.outputs.count == '0' && '100' || '0' }} - compile_score=${{ steps.compile.outputs.warnings == '0' && '100' || '80' }} - test_score=${{ steps.tests.outputs.success_rate }} - 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 - elif (( $(echo "$overall_score >= 80" | bc -l) )); then - echo "overall_status=โœ… Good" >> $GITHUB_OUTPUT - elif (( $(echo "$overall_score >= 70" | bc -l) )); then - echo "overall_status=โš ๏ธ Needs Improvement" >> $GITHUB_OUTPUT - else - echo "overall_status=โŒ Poor" >> $GITHUB_OUTPUT + echo "tests: ${{ needs.tests.result }}" + echo "static: ${{ needs.static.result }}" + echo "migrations: ${{ needs.migrations.result }}" + if [ "${{ needs.tests.result }}" != "success" ] || \ + [ "${{ needs.static.result }}" != "success" ] || \ + [ "${{ needs.migrations.result }}" != "success" ]; then + echo "::error::Required jobs did not pass" + exit 1 fi - continue-on-error: true - - name: Find existing PR comment - if: github.event_name == 'pull_request' + comment: + name: PR comment + runs-on: ubuntu-latest + needs: [tests, static, dialyzer] + if: always() && github.event_name == 'pull_request' + + steps: + # Fork PRs get a read-only GITHUB_TOKEN, so commenting 403s there. This + # job is purely informational and never gates a merge, so degrade quietly + # rather than painting the run red. + - name: Find existing comment id: find_comment + continue-on-error: true uses: peter-evans/find-comment@v3 with: 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' + - name: Create or update comment + continue-on-error: true uses: peter-evans/create-or-update-comment@v4 with: comment-id: ${{ steps.find_comment.outputs.comment-id }} @@ -272,62 +415,30 @@ jobs: 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` | - | ๐Ÿ”จ **Compilation** | ${{ steps.compile.outputs.status }} | ${{ steps.compile.outputs.warnings }} warnings | `mix compile` | - | ๐Ÿงช **Tests** | ${{ steps.tests.outputs.status }} | ${{ steps.tests.outputs.failures }}/${{ steps.tests.outputs.total }} failed | Success rate: ${{ steps.tests.outputs.success_rate }}% | - | ๐Ÿ“Š **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 + | Category | Result | Gates merge? | + |----------|--------|--------------| + | ๐Ÿงช **Tests** (${{ env.PARTITIONS }} shards) | ${{ needs.tests.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} | โœ… yes | + | ๐Ÿ“ **Formatting** / **Compile** | ${{ needs.static.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} | โœ… yes | + | โš ๏ธ **Compile warnings** | ${{ needs.static.outputs.warnings || 'n/a' }} | advisory | + | ๐ŸŽฏ **Credo** | ${{ needs.static.outputs.credo || 'n/a' }} issues | advisory | + | ๐Ÿ” **Dialyzer** | ${{ needs.dialyzer.outputs.warnings || 'n/a' }} warnings | advisory | - 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) - - **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) + Full output for the advisory checks is attached to this run as + build artifacts. Coverage runs on pushes to the default branch, + not on PRs.
- ๐Ÿ“ˆ Progress Toward Goals + ๐Ÿ”ง Reproduce locally - 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 }}) - -
- -
- ๐Ÿ”ง Quick Actions - - To improve code quality: ```bash - # Fix formatting issues mix format - - # View detailed Credo analysis + mix test mix credo --strict - - # Check Dialyzer warnings mix dialyzer - - # Generate detailed coverage report - mix coveralls.html ```
--- - ๐Ÿค– *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. + ๐Ÿค– *Auto-generated by GitHub Actions* diff --git a/.github/workflows/zoo-deploy.yml b/.github/workflows/zoo-deploy.yml new file mode 100644 index 000000000..a8e58b436 --- /dev/null +++ b/.github/workflows/zoo-deploy.yml @@ -0,0 +1,213 @@ +name: ๐Ÿš€ Zoo Deploy + +# Deploys are gated on a green test suite, so the trigger is the test workflow +# finishing โ€” not the push itself. `on: push` would fire while the suite was +# still running and ship a red commit. The suite is now the ONLY gate: the +# `production-deploy` environment carries no approval rule, so a green push to +# guarzo/zoo reaches production unattended. +on: + workflow_run: + workflows: ["๐Ÿงช Test Suite"] + types: [completed] + branches: [guarzo/zoo] + workflow_dispatch: + inputs: + ref: + description: 'Tag or SHA to deploy (defaults to guarzo/zoo HEAD)' + required: false + type: string + +# Default token is read-only; the job scopes itself up because it pushes a tag. +# The tag is the ONLY record of what is in production โ€” no branch tracks it, by +# design (see docs/ZOO-FORK.md, "Deployment"). +permissions: + contents: read + +jobs: + deploy: + name: Deploy to Fly + # workflow_run fires on EVERY completion of the test suite โ€” GitHub offers + # no conclusion filter on the trigger itself, so a red suite still creates a + # Zoo Deploy run. This condition is what makes it inert: the single job is + # skipped, so no environment is referenced and no credential is released. + # Expect skipped runs in the Actions tab after every failed suite; that is + # the mechanism working, not a misfire. + # + # The `event == 'push'` clause guards a narrower hole, and now guards it + # ALONE: workflow_run's `branches:` filter (above) matches the triggering + # run's head_branch, and this is a public fork whose default branch is itself + # named `guarzo/zoo`. Without this clause a fork PR whose source branch is + # also named `guarzo/zoo` would match the filter and, if its tests passed, + # deploy a commit the requester controls. That used to surface as an approval + # request a human could reject; with the approval rule gone it would deploy + # straight to production. Do not weaken this clause. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push') + runs-on: ubuntu-latest + + # KEEP THIS LINE. It no longer gates anything โ€” the environment's approval + # rule was removed deliberately โ€” but FLY_DEPLOY_TOKEN is an ENVIRONMENT + # secret, not a repository secret, and is readable only by a job that + # references its environment. Deleting this line does not fail loudly: + # `secrets.FLY_DEPLOY_TOKEN` resolves to the empty string and the deploy + # fails at `flyctl deploy` with an auth error. Keeping the token scoped to + # this environment is also what stops other workflows from reading the + # production credential (see the FLY_API_TOKEN note on the deploy step). + environment: production-deploy + + permissions: + contents: write + + # NEVER set cancel-in-progress: true here. A merge landing mid-deploy would + # kill this job while Fly's builder keeps going, changing production with no + # tag โ€” and the tag is the only record of what is live. That is the exact + # failure this workflow exists to prevent. `false` also serializes deploys + # onto the single machine, which is what makes the staleness guard below + # still necessary now that runs no longer wait for a human. + concurrency: + group: zoo-deploy-run + cancel-in-progress: false + + steps: + - name: Resolve the ref to deploy + id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_REF: ${{ inputs.ref }} + RUN_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + REF="${DISPATCH_REF:-guarzo/zoo}" + else + # NOT github.ref: under workflow_run that resolves to the default + # branch tip at trigger time, which may not be the tested commit. + REF="$RUN_SHA" + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "Resolved deploy ref: $REF" + + - name: Check out the ref + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ steps.resolve.outputs.ref }} + # Annotated tagging needs full history. + fetch-depth: 0 + + # Still load-bearing without the approval gate. Runs no longer sit pending + # for hours, but `cancel-in-progress: false` queues them: while one deploy + # runs, later pushes stack up behind it and each still checks out its own + # older SHA. This is what stops a queued run from shipping a commit that + # guarzo/zoo has already moved past. + - name: Guard against a superseded commit + id: guard + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "workflow_dispatch: staleness guard skipped โ€” deploying a ref that is not the branch tip is what rollback is for." + echo "proceed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch --quiet origin guarzo/zoo + TIP="$(git rev-parse origin/guarzo/zoo)" + HERE="$(git rev-parse HEAD)" + if [ "$TIP" != "$HERE" ]; then + echo "::notice title=Superseded::guarzo/zoo has moved to ${TIP}; this run was queued for ${HERE}. Nothing was deployed." + # Also write to the job summary, not just the notice: a notice + # requires opening the run to see, so without this the summary + # pane stays blank and a superseded run reads identically to a + # real deploy in the Actions list. + { + echo "### Not deployed โ€” superseded" + echo "" + echo "guarzo/zoo has moved to \`${TIP}\`; this run was queued for \`${HERE}\`. Nothing was deployed." + } >> "$GITHUB_STEP_SUMMARY" + echo "proceed=false" >> "$GITHUB_OUTPUT" + else + echo "proceed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Set up flyctl + if: steps.guard.outputs.proceed == 'true' + uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # v1 + + # Fly runs release_command first (fly.toml:29 โ€” migrations against + # DIRECT_DATABASE_URL), so a failed migration fails the deploy before new + # code serves traffic. Under strategy = 'rolling' (fly.toml:30) this + # blocks on the /health check (fly.toml:84-88). + - name: Deploy to Fly + if: steps.guard.outputs.proceed == 'true' + id: deploy + env: + # FLY_DEPLOY_TOKEN is an ENVIRONMENT secret on `production-deploy` + # (not a repository secret), deliberately NOT named FLY_API_TOKEN: + # advanced-test.yml reads a secret of that name with no environment + # reference at all, for the separate wanderer-test app. The distinct + # name plus the environment scoping mean this production credential + # can never be picked up there. The env var flyctl itself reads is + # still FLY_API_TOKEN. + FLY_API_TOKEN: ${{ secrets.FLY_DEPLOY_TOKEN }} + run: | + set -euo pipefail + flyctl deploy --app wanderer --remote-only + # Recorded in the tag message so a tag can be matched to a Fly release + # without the dashboard. Best-effort: a lookup failure must not fail a + # deploy that already succeeded. + # The key is `Version`, capital V โ€” flyctl serializes Go field names. + # A lowercase `.version` does not error, it yields null, which reaches + # the tag message as the literal "release vnull". Hence the explicit + # `// "unknown"` and the empty-string guard: every failure mode here + # is silent, so each one needs its own fallback. + VERSION="$(flyctl releases --app wanderer --json | jq -r '.[0].Version // "unknown"' || echo unknown)" + echo "version=${VERSION:-unknown}" >> "$GITHUB_OUTPUT" + + # Everything below runs only after a healthy deploy. That ordering is what + # makes the tag mean "this commit served production traffic". + - name: Tag the deployed commit + if: steps.guard.outputs.proceed == 'true' + id: tag + env: + DEPLOY_VERSION: ${{ steps.deploy.outputs.version }} + run: | + set -euo pipefail + SHA="$(git rev-parse --short HEAD)" + git config user.name "github-actions" + git config user.email "github-actions@github.com" + # Convergent, not merely collision-safe. A recovery re-run (deploy + # succeeded, tag push failed) must reuse the tag the first run + # created, not mint a second one for the same commit โ€” the tag name is + # generated from the clock, so checking only the new name would always + # miss the existing one. + # Tags are present because checkout used fetch-depth: 0. + # 'v20[0-9]*' (not 'v[0-9]*') excludes upstream semver release tags + # like v1.2.3 โ€” git tag sorts lexicographically, so head -n1 would + # otherwise prefer an upstream tag over a timestamped deploy tag on + # any commit carrying both. + EXISTING="$(git tag --points-at HEAD --list 'v20[0-9]*' | head -n1)" + if [ -n "${EXISTING}" ]; then + TAG="${EXISTING}" + echo "Commit is already tagged ${TAG}; reusing it." + else + TAG="v$(date -u +%Y%m%d%H%M%S)" + git tag -a "${TAG}" -m "Deployed ${SHA} to Fly app wanderer (release v${DEPLOY_VERSION})" + git push origin "${TAG}" + echo "Tagged ${SHA} as ${TAG}" + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Summarize + if: steps.guard.outputs.proceed == 'true' + env: + DEPLOY_TAG: ${{ steps.tag.outputs.tag }} + run: | + { + echo "### Deployed" + echo "" + echo "- commit: \`$(git rev-parse HEAD)\`" + echo "- tag: \`${DEPLOY_TAG}\`" + echo "- app: \`wanderer\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 27425e45b..17464ad2b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ erl_crash.dump /config/*.secret.exs .elixir_ls/ +# Devcontainer host-specific files +/.devcontainer/docker-compose.override.yml + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f26de6d..cea5f824c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ +## Unreleased + +### Behaviour Changes: + +* discord: a map with Discord kill notifications configured now posts **more** + kills than before. Kills involving characters tracked on the map are delivered + even when they fall outside wormhole space or occur in an excluded system, so a + channel tuned to the previous volume will get busier without anyone changing a + setting. Narrow it with the wormhole-only filter, the excluded-systems list, or + by routing character kills to a separate channel with the new character webhook. + ## [v1.101.7](https://github.com/wanderer-industries/wanderer/compare/v1.101.6...v1.101.7) (2026-07-17) diff --git a/README.md b/README.md index e51cfd146..9a890d6af 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,71 @@ Our only source of funding is your donations. Wanderer is a standard Elixir/Phoenix application backed by a PostgreSQL database for general data. On the frontend we use [TailwindCSS](https://tailwindcss.com/) for styling and React to make the map interactive. +## Features + +### Discord kill notifications + +Map owners and admins can configure Discord webhooks under **Map settings โ†’ +Notifications** to receive kill notifications for systems on that map, +optionally filtered to wormhole space and excluding chosen systems. The filter +is server-side and per-map โ€” it is separate from the per-user filters of the +in-app kills widget. + +There are two destinations per map, each with its own webhook, enabled flag and +health state. The **system** destination receives kills in the map's systems. +The optional **character** destination receives kills involving the map's +tracked characters, so they can be routed to a separate channel. If a +destination is disabled the kills for that role are dropped โ€” they are never +rerouted to the other channel. + +Requires `WANDERER_WEBHOOKS_ENABLED=true`. When it is false the delivery workers +are not running and "Send test message" reports that notifications are disabled +on this server. `WANDERER_DISCORD_POOL_SIZE` (default `10`) sizes the isolated +Finch connection pool used for Discord delivery. +`WANDERER_DISCORD_MAX_KILLMAIL_AGE_SECONDS` (default `3600`) drops killmails +older than the given age, so an upstream replay does not post stale kills. +The dedup marks that stop a killmail being posted twice are held in memory, so a +restart loses them and the upstream service then replays recent kills. For +`WANDERER_DISCORD_STARTUP_GRACE_SECONDS` (default `600`) after the marks are +lost, `WANDERER_DISCORD_STARTUP_MAX_KILLMAIL_AGE_SECONDS` (default `120`) +applies instead of the hour above, so the replayed history is dropped for being +old while a kill that genuinely happens during the window still posts. Set the +grace to `0` to disable the window. + +`WANDERER_NOTABLE_ITEMS_ENABLED` (default `false`) adds a "Notable Items" +section to each kill embed, listing the most valuable loot that *dropped* +(destroyed modules are excluded). It is off by default because building it costs +one extra ESI killmail fetch per kill plus a market price lookup, on the +dispatcher's critical path. `WANDERER_NOTABLE_ITEMS_THRESHOLD_ISK` (default +`50000000`) sets the minimum value an item must exceed to be listed, +`WANDERER_NOTABLE_ITEMS_LIMIT` (default `5`) caps how many are listed per kill, +and `WANDERER_NOTABLE_ITEMS_TIMEOUT_MS` (default `1500`) bounds how long +enrichment may hold up a batch โ€” raising it delays kill notifications for every +map on the instance. Prices are Jita 4-4 quotes; abyssal modules are listed +without a price, since market quotes for them are not meaningful. Any failure โ€” +timeout, ESI error, unavailable pricing โ€” simply omits the section; the kill is +still posted. + +Corporation tickers are filled in from ESI when a killmail reaches the +dispatcher without them, so the `(TICKER)` after each pilot name is not lost to +an upstream payload that arrived unenriched. This is on by default: it is one +lookup per corporation, cached for an hour, and only for the kills actually +being posted. `WANDERER_CORP_TICKERS_TIMEOUT_MS` (default `1500`) bounds how +long those lookups may hold up a batch, and `WANDERER_CORP_TICKERS_ENABLED` +(default `true`) is an incident switch for stopping the lookups without a +deploy โ€” turning it off means embeds lose the ticker again. A failure omits the +ticker; the kill is still posted. + +Mentions are a per-map, per-webhook opt-in, so an instance with nothing +configured pings nobody. `WANDERER_DISCORD_MENTIONS_ENABLED` (default `true`) +is the instance-wide incident switch for them: turning it off silences every +role and user ping โ€” on kill and route notifications alike โ€” without touching +per-map configuration or waiting for a deploy. + +The webhook URL is stored encrypted and is never displayed in full after it is +saved โ€” the settings tab shows only a masked hint. Pointing a destination at a +different channel means entering the full URL again. + ## Development ### Setup diff --git a/assets/js/hooks/Mapper/components/characters/Characters.tsx b/assets/js/hooks/Mapper/components/characters/Characters.tsx index 3fc6c26cb..0ae276f18 100644 --- a/assets/js/hooks/Mapper/components/characters/Characters.tsx +++ b/assets/js/hooks/Mapper/components/characters/Characters.tsx @@ -1,10 +1,10 @@ import { emitMapEvent } from '@/hooks/Mapper/events'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; import { CharacterTypeRaw } from '@/hooks/Mapper/types'; -import { Commands, OutCommand } from '@/hooks/Mapper/types/mapHandlers.ts'; +import { Commands, OutCommand } from '@/hooks/Mapper/types/mapHandlers'; import { useAutoAnimate } from '@formkit/auto-animate/react'; import clsx from 'clsx'; -import { useCallback } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { TooltipPosition, WdEveEntityPortrait, @@ -17,6 +17,20 @@ interface CharactersProps { data: CharacterTypeRaw[]; } +function getTooltipContent( + name: string, + isExpired: boolean, + trackingPaused: boolean, + online: boolean, + isReady: boolean, +): string { + if (isExpired) return `Token is expired for ${name}`; + if (trackingPaused) return `${name} - Tracking Paused (click to resume)`; + if (!online) return `${name} - Offline`; + if (isReady) return `${name} - Ready for combat (right-click to unready)`; + return `${name} (right-click to mark as ready)`; +} + export const Characters = ({ data }: CharactersProps) => { const [parent] = useAutoAnimate(); @@ -25,63 +39,174 @@ export const Characters = ({ data }: CharactersProps) => { data: { mainCharacterEveId, followingCharacterEveId, expiredCharacters }, } = useMapRootState(); - const handleSelect = useCallback(async (character: CharacterTypeRaw) => { - if (!character) { - return; - } + const handleSelect = useCallback( + async (character: CharacterTypeRaw) => { + if (!character) return; + + await outCommand({ + type: OutCommand.startTracking, + data: { character_eve_id: character.eve_id }, + }); + emitMapEvent({ + name: Commands.centerSystem, + data: character.location?.solar_system_id?.toString() ?? '', + }); + }, + [outCommand], + ); + + // The server takes the whole ready list, not an add/remove, so two toggles + // fired inside one round-trip would both derive their replacement list from + // the same rendered `data` and the second would undo the first. Chain the + // requests and keep the list we last sent, so each toggle composes onto the + // previous one instead of onto a stale snapshot. + // + // The optimistic list is held until `data` actually reflects it. Dropping it + // when the queue drains is too early: `outCommand` resolving does not mean + // the map-state broadcast has landed, so the next toggle would re-derive from + // `data` that still shows the pre-toggle set and repeat the toggle instead of + // reversing it. + const pendingReadyRef = useRef(null); + const inFlightRef = useRef(0); + const readyQueueRef = useRef>(Promise.resolve()); + const submittedReadyRef = useRef(null); + const settleTimerRef = useRef | null>(null); - await outCommand({ - type: OutCommand.startTracking, - data: { character_eve_id: character.eve_id }, - }); - emitMapEvent({ - name: Commands.centerSystem, - data: character.location?.solar_system_id?.toString(), - }); + const clearPendingReady = useCallback(() => { + pendingReadyRef.current = null; + submittedReadyRef.current = null; + + if (settleTimerRef.current !== null) { + clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; + } }, []); - const items = data.map(character => { - const isExpired = expiredCharacters.includes(character.eve_id); - - return ( -
  • handleSelect(character)} - > - - { + const submitted = submittedReadyRef.current; + if (submitted === null || inFlightRef.current > 0) return; + + const actual = (data || []).filter(char => char.ready).map(char => char.eve_id); + + if (actual.length === submitted.length && submitted.every(id => actual.includes(id))) { + clearPendingReady(); + } + }, [data, clearPendingReady]); + + useEffect(() => clearPendingReady, [clearPendingReady]); + + const handleToggleReady = useCallback( + async (character: CharacterTypeRaw, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!character.online) return; + + inFlightRef.current += 1; + + const run = readyQueueRef.current.then(async () => { + const currentReadyCharacters = + pendingReadyRef.current ?? (data || []).filter(char => char.ready).map(char => char.eve_id); + const newList = currentReadyCharacters.includes(character.eve_id) + ? currentReadyCharacters.filter(id => id !== character.eve_id) + : [...currentReadyCharacters, character.eve_id]; + + pendingReadyRef.current = newList; + + try { + await outCommand({ + type: OutCommand.updateReadyCharacters, + data: { ready_character_eve_ids: newList }, + }); + + submittedReadyRef.current = newList; + + // A broadcast that never matches โ€” the server clamped or rejected the + // list โ€” must not pin the optimistic view forever. Fall back to + // whatever the server actually has after a bounded wait. + // + // Only when nothing is in flight. This timer belongs to the toggle + // that armed it; a newer toggle issued just before it fires has + // already read `pendingReadyRef` but has not yet resolved to re-arm. + // Clearing there would send the toggle after it back to `data`, which + // does not yet reflect the in-flight update โ€” the exact stale-snapshot + // compose this ref exists to prevent. The in-flight request re-arms on + // resolve and clears outright on failure, so the fallback is deferred, + // never lost. + if (settleTimerRef.current !== null) clearTimeout(settleTimerRef.current); + settleTimerRef.current = setTimeout(() => { + if (inFlightRef.current === 0) clearPendingReady(); + }, 5000); + } catch (err) { + console.error('Failed to update ready characters:', err); + // Drop the optimistic list so the next toggle re-derives from + // whatever the server actually has. + clearPendingReady(); + } finally { + inFlightRef.current -= 1; + } + }); + + readyQueueRef.current = run; + await run; + }, + [data, outCommand, clearPendingReady], + ); + + const items = useMemo( + () => + (data || []).map(character => { + const isExpired = expiredCharacters.includes(character.eve_id); + const isReady = character.ready || false; + const tooltip = getTooltipContent( + character.name, + isExpired, + character.tracking_paused, + character.online, + isReady, + ); + + return ( +
  • handleSelect(character)} + onContextMenu={e => handleToggleReady(character, e)} > - - - -
  • - ); - }); + + + + + + + ); + }), + [data, handleSelect, handleToggleReady, mainCharacterEveId, followingCharacterEveId, expiredCharacters], + ); return (
      diff --git a/assets/js/hooks/Mapper/components/characters/components/WdCharStateWrapper.tsx b/assets/js/hooks/Mapper/components/characters/components/WdCharStateWrapper.tsx index 0d63b0a30..de9cf7d3c 100644 --- a/assets/js/hooks/Mapper/components/characters/components/WdCharStateWrapper.tsx +++ b/assets/js/hooks/Mapper/components/characters/components/WdCharStateWrapper.tsx @@ -10,6 +10,8 @@ type WdCharStateWrapperProps = { isExpired?: boolean; isMain?: boolean; isFollowing?: boolean; + isReady?: boolean; + isTrackingPaused?: boolean; location: LocationRaw | null; isOnline: boolean; } & WithChildren; @@ -20,6 +22,8 @@ export const WdCharStateWrapper = ({ isMain, isFollowing, isExpired, + isReady, + isTrackingPaused, children, }: WdCharStateWrapperProps) => { return ( @@ -29,12 +33,24 @@ export const WdCharStateWrapper = ({ 'flex w-[35px] h-[35px] rounded-[4px] border-[1px] border-solid bg-transparent cursor-pointer', 'transition-colors duration-250 hover:bg-stone-300/90', { - ['border-stone-800/90']: !isExpired && !isOnline, - ['border-lime-600/70']: !isExpired && isOnline, + ['border-stone-800/90']: !isExpired && !isOnline && !isReady, + ['border-lime-600/70']: !isExpired && isOnline && !isReady, + ['border-orange-500/90']: !isExpired && isReady && isOnline, + ['border-orange-700/70']: !isExpired && isReady && !isOnline, ['border-red-600/70']: isExpired, }, )} > + {isTrackingPaused && ( + + )} {isMain && ( )} + {isReady && ( + + )} {isDocked(location) &&
      } {isExpired && ( void, onCustomLabelDialog: () => void, + disabled = false, ): (() => MenuItem[]) => { - const ref = useRef({ onSystemLabels, systemId, systems, onCustomLabelDialog }); - ref.current = { onSystemLabels, systemId, systems, onCustomLabelDialog }; + const ref = useRef({ onSystemLabels, systemId, systems, onCustomLabelDialog, disabled }); + ref.current = { onSystemLabels, systemId, systems, onCustomLabelDialog, disabled }; return useCallback(() => { - const { onSystemLabels, systemId, systems, onCustomLabelDialog } = ref.current; + const { onSystemLabels, systemId, systems, disabled } = ref.current; const system = systemId ? getSystemById(systems, systemId) : undefined; const labels = new LabelsManager(system?.labels ?? ''); @@ -53,29 +54,13 @@ export const useLabelsMenu = ( { label: 'Labels', icon: PrimeIcons.BOOKMARK, + disabled, className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: hasLabels }), items: [ - ...(labels.customLabel.length > 0 - ? [ - { - label: 'Clear custom label', - icon: 'pi pi-trash', - command: () => { - labels.updateCustomLabel(''); - onSystemLabels(labels.toString()); - }, - }, - ] - : []), - { - label: 'Custom label', - icon: 'pi pi-language', - command: onCustomLabelDialog, - }, - { separator: true }, ...statusList.map(x => ({ label: LABELS_INFO[x].name, icon: x === LABELS.clear ? PrimeIcons.TRASH : PrimeIcons.BOOKMARK, + disabled, command: () => { if (x === LABELS.clear) { labels.clearLabels(); diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useStatusMenu/useStatusMenu.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useStatusMenu/useStatusMenu.ts index 80ef71252..c03b85d09 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useStatusMenu/useStatusMenu.ts +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useStatusMenu/useStatusMenu.ts @@ -4,19 +4,20 @@ import { useCallback, useRef } from 'react'; import { SolarSystemRawType } from '@/hooks/Mapper/types'; import { getSystemById } from '@/hooks/Mapper/helpers'; import clsx from 'clsx'; -import { STATUS_COLOR_CLASSES, STATUS_NAMES, STATUSES_ORDER } from '@/hooks/Mapper/components/map/constants.ts'; +import { STATUS_COLOR_CLASSES, STATUS_NAMES, STATUSES_ORDER } from '@/hooks/Mapper/components/map/constants'; import { GRADIENT_MENU_ACTIVE_CLASSES } from '@/hooks/Mapper/constants.ts'; export const useStatusMenu = ( systems: SolarSystemRawType[], systemId: string | undefined, onSystemStatus: (val: number) => void, + disabled = false, ): (() => MenuItem) => { - const ref = useRef({ onSystemStatus, systemId, systems }); - ref.current = { onSystemStatus, systemId, systems }; + const ref = useRef({ onSystemStatus, systemId, systems, disabled }); + ref.current = { onSystemStatus, systemId, systems, disabled }; return useCallback(() => { - const { onSystemStatus, systemId, systems } = ref.current; + const { onSystemStatus, systemId, systems, disabled } = ref.current; const system = systemId ? getSystemById(systems, systemId) : undefined; if (!system) { @@ -33,10 +34,12 @@ export const useStatusMenu = ( const menuItem: MenuItem = { label: 'Status', icon: PrimeIcons.BOLT, + disabled, className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelectedStatus }), items: statusList.map(x => ({ label: STATUS_NAMES[x], icon: x !== 0 ? `${PrimeIcons.BOLT} ${STATUS_COLOR_CLASSES[x]}` : PrimeIcons.BAN, + disabled, command: () => onSystemStatus(x), className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: x === system.status }), })), diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/index.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/index.ts index 59207777c..7a5ea35a1 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/index.ts +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/index.ts @@ -1 +1,3 @@ export * from './useTagMenu.tsx'; +export * from './useZooTagMenu.tsx'; +export * from './useThemeTagMenu'; diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx index 3cb804d51..37961d993 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useTagMenu.tsx @@ -36,12 +36,13 @@ export const useTagMenu = ( systems: SolarSystemRawType[], systemId: string | undefined, onSystemTag: (val?: string) => void, + disabled = false, ): (() => MenuItem) => { - const ref = useRef({ onSystemTag, systems, systemId }); - ref.current = { onSystemTag, systems, systemId }; + const ref = useRef({ onSystemTag, systems, systemId, disabled }); + ref.current = { onSystemTag, systems, systemId, disabled }; return useCallback(() => { - const { onSystemTag, systemId, systems } = ref.current; + const { onSystemTag, systemId, systems, disabled } = ref.current; const system = systemId ? getSystemById(systems, systemId) : undefined; const isSelectedTag = AVAILABLE_TAGS.includes(system?.tag ?? ''); @@ -49,11 +50,13 @@ export const useTagMenu = ( const menuItem: MenuItem = { label: 'Tag', icon: PrimeIcons.HASHTAG, + disabled, className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelectedTag }), items: [ { label: 'Digit', icon: PrimeIcons.TAGS, + disabled, className: '!h-[128px] suppress-menu-behaviour', template: () => { return ( @@ -66,6 +69,7 @@ export const useTagMenu = ( key={x} value={x} size="small" + disabled={disabled} className="p-[3px] justify-center" onClick={() => system?.tag !== x && onSystemTag(x)} > @@ -73,7 +77,7 @@ export const useTagMenu = ( ))} void, + disabled = false, +): (() => MenuItem) => { + const theme = useTheme(); + + const zooTags = useZooTagMenu(systems, systemId, onSystemTag, disabled); + const defaultTags = useTagMenu(systems, systemId, onSystemTag, disabled); + + return theme === AvailableThemes.zoo ? zooTags : defaultTags; +}; diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useZooTagMenu.tsx b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useZooTagMenu.tsx new file mode 100644 index 000000000..28abd85df --- /dev/null +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/hooks/useTagMenu/useZooTagMenu.tsx @@ -0,0 +1,97 @@ +// useZooTagMenu.ts +import { useCallback, useRef } from 'react'; +import { MenuItem } from 'primereact/menuitem'; +import { PrimeIcons } from 'primereact/api'; +import clsx from 'clsx'; +import { SolarSystemRawType } from '@/hooks/Mapper/types'; +import { getSystemById } from '@/hooks/Mapper/helpers'; +import { GRADIENT_MENU_ACTIVE_CLASSES } from '@/hooks/Mapper/constants'; +import { getCustomTagsForTheme, CustomTags } from '@/hooks/Mapper/components/map/helpers/getThemeBehavior'; + +/** + * Helper to determine if any tag is selected. + * + * @param systemTag - The tag string. + * @returns True if the tag is truthy. + */ +function isAnyTagSelected(systemTag?: string): boolean { + return Boolean(systemTag); +} + +/** + * Builds a zoo theme tag menu. + * This menu renders a top-level list of tags based on the zoo theme. + * + * @param system - The system object which may have an existing tag. + * @param onSystemTag - Callback to update the system tag. + * @param customTags - Custom tag definitions (should come from the zoo theme). + * @param disabled - Whether the menu and its entries are read-only. + * @returns A MenuItem representing the zoo theme tag menu. + */ +function buildZooThemeMenu( + system: SolarSystemRawType | undefined, + onSystemTag: (val?: string) => void, + customTags: CustomTags, + disabled: boolean, +): MenuItem { + const tag = system?.tag || ''; + const isSelected = isAnyTagSelected(tag); + const zooTags = customTags.others ?? []; + + return { + label: 'Occupied', + icon: PrimeIcons.HASHTAG, + disabled, + className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: isSelected }), + items: [ + // Include a "Clear" option if a tag is already set. + ...(tag + ? [ + { + label: 'Clear', + icon: PrimeIcons.BAN, + disabled, + command: () => onSystemTag(), + }, + ] + : []), + // Build a menu item for each zoo tag. + ...zooTags.map(zooTag => ({ + label: zooTag, + icon: PrimeIcons.TAG, + disabled, + command: () => onSystemTag(zooTag), + className: clsx({ [GRADIENT_MENU_ACTIVE_CLASSES]: tag === zooTag }), + })), + ], + }; +} + +/** + * Custom hook to generate a tag menu for a given system that always uses zoo theme settings. + * + * @param systems - Array of available systems. + * @param systemId - ID of the current system. + * @param onSystemTag - Callback to update the system tag. + * @param disabled - Whether the menu and its entries are read-only. + * @returns A memoized function that returns a MenuItem for the zoo theme. + */ +export const useZooTagMenu = ( + systems: SolarSystemRawType[], + systemId: string | undefined, + onSystemTag: (val?: string) => void, + disabled = false, +): (() => MenuItem) => { + // Keep the latest values in a ref to avoid extra dependencies. + const ref = useRef({ onSystemTag, systems, systemId, disabled }); + ref.current = { onSystemTag, systems, systemId, disabled }; + + // Always use the zoo theme's custom tags. + const customTags: CustomTags = getCustomTagsForTheme('zoo'); + + return useCallback(() => { + const { systems, systemId, onSystemTag, disabled } = ref.current; + const system = systemId ? getSystemById(systems, systemId) : undefined; + return buildZooThemeMenu(system, onSystemTag, customTags, disabled); + }, [customTags]); +}; diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemHandlers.ts b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemHandlers.ts index 7c1afd7f8..0b4545664 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemHandlers.ts +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemHandlers.ts @@ -142,6 +142,42 @@ export const useContextMenuSystemHandlers = ({ setSystem(undefined); }, []); + const onSystemCustomFlags = useCallback((selectedFlags?: string) => { + const { system, outCommand } = ref.current; + if (!system) { + return; + } + + outCommand({ + type: OutCommand.updateSystemCustomFlags, + data: { + system_id: system, + value: selectedFlags ?? '', + }, + }); + setSystem(undefined); + }, []); + + const onSystemOwner = useCallback((ownerId?: string, ownerType?: string) => { + const { system, outCommand } = ref.current; + if (!system) { + return; + } + + outCommand({ + type: OutCommand.updateSystemOwner, + data: { + system_id: system, + // Snake_case: the server reads these with `Map.get(params, "owner_id")` + // / `"owner_type"`. Sent as ownerId/ownerType they were silently + // dropped, so setting an owner from the context menu always cleared it. + owner_id: ownerId, + owner_type: ownerType, + }, + }); + setSystem(undefined); + }, []); + const onSystemStatus = useCallback((status: number) => { const { system, outCommand } = ref.current; if (!system) { @@ -218,6 +254,8 @@ export const useContextMenuSystemHandlers = ({ // onTogglePingRally, onSystemTag, onSystemTemporaryName, + onSystemCustomFlags, + onSystemOwner, onSystemStatus, onSystemLabels, onOpenSettings, diff --git a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemItems.tsx b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemItems.tsx index c96d495de..4555e6f27 100644 --- a/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemItems.tsx +++ b/assets/js/hooks/Mapper/components/contexts/ContextMenuSystem/useContextMenuSystemItems.tsx @@ -1,7 +1,7 @@ import { useLabelsMenu, useStatusMenu, - useTagMenu, + useThemeTagMenu, useUserRoute, } from '@/hooks/Mapper/components/contexts/ContextMenuSystem/hooks'; import { useMemo } from 'react'; @@ -39,19 +39,22 @@ export const useContextMenuSystemItems = ({ userHubs, systems, }: Omit) => { - const getTags = useTagMenu(systems, systemId, onSystemTag); - const getStatus = useStatusMenu(systems, systemId, onSystemStatus); - const getLabels = useLabelsMenu(systems, systemId, onSystemLabels, onCustomLabelDialog); + const { + data: { pings, isSubscriptionActive, options: mapOptions }, + } = useMapRootState(); + + // Intel-managed fields are owned by the source map, so their menus are read-only here. + const hasIntelSource = !!mapOptions?.intel_source_map_id; + + const getTags = useThemeTagMenu(systems, systemId, onSystemTag, hasIntelSource); + const getStatus = useStatusMenu(systems, systemId, onSystemStatus, hasIntelSource); + const getLabels = useLabelsMenu(systems, systemId, onSystemLabels, onCustomLabelDialog, hasIntelSource); const getWaypointMenu = useWaypointMenu(onWaypointSet); const canLockSystem = useMapCheckPermissions([UserPermission.LOCK_SYSTEM]); const canManageSystem = useMapCheckPermissions([UserPermission.UPDATE_SYSTEM]); const canDeleteSystem = useMapCheckPermissions([UserPermission.DELETE_SYSTEM]); const getUserRoutes = useUserRoute({ userHubs, systemId, onUserHubToggle }); - const { - data: { pings, isSubscriptionActive }, - } = useMapRootState(); - const ping = useMemo(() => (pings.length === 1 ? pings[0] : undefined), [pings]); const isShowPingBtn = useMemo(() => { if (!isSubscriptionActive) { @@ -185,20 +188,22 @@ export const useContextMenuSystemItems = ({ }, [ systemId, systems, + ping?.solar_system_id, getTags, getStatus, getLabels, getWaypointMenu, - getUserRoutes, hubs, onHubToggle, + getUserRoutes, + isShowPingBtn, canLockSystem, onLockToggle, + canManageSystem, canDeleteSystem, onDeleteSystem, onOpenSettings, onTogglePing, - ping, - isShowPingBtn, + hasIntelSource, ]); }; diff --git a/assets/js/hooks/Mapper/components/map/Map.tsx b/assets/js/hooks/Mapper/components/map/Map.tsx index de649c80a..8c27229c5 100644 --- a/assets/js/hooks/Mapper/components/map/Map.tsx +++ b/assets/js/hooks/Mapper/components/map/Map.tsx @@ -123,7 +123,7 @@ const MapComp = ({ const { handleRootContext, ...rootCtxProps } = useContextMenuRootHandlers({ onAddSystem, onCommand }); const { handleConnectionContext, ...connectionCtxProps } = useContextMenuConnectionHandlers(); const { update } = useMapState(); - const { variant, gap, size, color } = useBackgroundVars(theme); + const { variant, gap, size, color, snapSizeX, snapSizeY } = useBackgroundVars(theme); const { isPanAndDrag, nodeComponent, connectionMode } = getBehaviorForTheme(theme || 'default'); const refVars = useRef({ onChangeViewport }); @@ -241,13 +241,12 @@ const MapComp = ({ onNodesChange={handleNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} - // TODO we need save into session all of this - // and on any action do either defaultViewport={defaultViewport} edgeTypes={edgeTypes} nodeTypes={nodeTypes} connectionMode={connectionMode} snapToGrid + snapGrid={[snapSizeX, snapSizeY]} nodeDragThreshold={10} onNodeDragStop={handleDragStop} onSelectionDragStop={handleSelectionDragStop} diff --git a/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/ContextMenuConnection.module.scss b/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/ContextMenuConnection.module.scss index 0b169c125..05debe926 100644 --- a/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/ContextMenuConnection.module.scss +++ b/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/ContextMenuConnection.module.scss @@ -16,6 +16,10 @@ background-image: linear-gradient(207deg, transparent, var(--conn-save)); } +.ConnectionLoop { + background-image: linear-gradient(207deg, transparent, var(--conn-loop)); +} + .SelectedItem { background-color: var(--selected-item-bg); } diff --git a/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/ContextMenuConnection.tsx b/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/ContextMenuConnection.tsx index 1fdde06f5..0dcea1fd5 100644 --- a/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/ContextMenuConnection.tsx +++ b/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/ContextMenuConnection.tsx @@ -7,7 +7,6 @@ import React, { RefObject, useMemo } from 'react'; import { Edge } from 'reactflow'; import { LifetimeActionsWrapper } from '@/hooks/Mapper/components/map/components/ContextMenuConnection/LifetimeActionsWrapper.tsx'; import { MassStatusActionsWrapper } from '@/hooks/Mapper/components/map/components/ContextMenuConnection/MassStatusActionsWrapper.tsx'; -import { ShipSizeActionsWrapper } from '@/hooks/Mapper/components/map/components/ContextMenuConnection/ShipSizeActionsWrapper.tsx'; import classes from './ContextMenuConnection.module.scss'; import { getSystemStaticInfo } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic.ts'; import { isNullsecSpace } from '@/hooks/Mapper/components/map/helpers/isKnownSpace.ts'; @@ -20,6 +19,7 @@ export interface ContextMenuConnectionProps { onChangeShipSizeStatus(state: ShipSizeStatus): void; onChangeType(type: ConnectionType): void; onToggleMassSave(isLocked: boolean): void; + onToggleLoop(): void; onHide(): void; edge?: Edge; } @@ -32,6 +32,7 @@ export const ContextMenuConnection: React.FC = ({ onChangeShipSizeStatus, onChangeType, onToggleMassSave, + onToggleLoop, onHide, edge, }) => { @@ -47,6 +48,9 @@ export const ContextMenuConnection: React.FC = ({ sourceInfo && targetInfo && isNullsecSpace(sourceInfo.system_class) && isNullsecSpace(targetInfo.system_class); const isFrigateSize = edge.data?.ship_size_type === ShipSizeStatus.small; + const isLoop = edge.data?.type === ConnectionType.loop; + // No `isWormholeType` binding: the bridge and gate types return early + // above, so everything reaching the menu below is already wormhole-or-loop. if (edge.data?.type === ConnectionType.bridge) { return [ @@ -96,12 +100,23 @@ export const ContextMenuConnection: React.FC = ({ ] : []), { - className: clsx(classes.FastActions, '!h-[64px]'), - template: () => { - return ( - - ); - }, + label: `Loop`, + className: clsx({ + [classes.ConnectionLoop]: isLoop, + }), + icon: PrimeIcons.REPLAY, + command: onToggleLoop, + }, + { + label: `Frigate`, + className: clsx({ + [classes.ConnectionFrigate]: isFrigateSize, + }), + icon: PrimeIcons.CLOUD, + command: () => + onChangeShipSizeStatus( + edge.data?.ship_size_type === ShipSizeStatus.small ? ShipSizeStatus.large : ShipSizeStatus.small, + ), }, { label: `Save mass`, @@ -133,6 +148,7 @@ export const ContextMenuConnection: React.FC = ({ onChangeType, onChangeShipSizeStatus, onToggleMassSave, + onToggleLoop, onChangeMassState, ]); @@ -141,4 +157,4 @@ export const ContextMenuConnection: React.FC = ({ ); -}; +}; \ No newline at end of file diff --git a/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/useContextMenuConnectionHandlers.ts b/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/useContextMenuConnectionHandlers.ts index d08bc3e7d..7bf546f41 100644 --- a/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/useContextMenuConnectionHandlers.ts +++ b/assets/js/hooks/Mapper/components/map/components/ContextMenuConnection/useContextMenuConnectionHandlers.ts @@ -125,6 +125,25 @@ export const useContextMenuConnectionHandlers = () => { }); }, []); + const onToggleLoop = useCallback(() => { + const { edge, outCommand } = ref.current; + + if (!edge || !edge.data) { + return; + } + + const newType = edge.data.type === ConnectionType.loop ? ConnectionType.wormhole : ConnectionType.loop; + + outCommand({ + type: OutCommand.updateConnectionType, + data: { + source: edge.source, + target: edge.target, + value: newType, + }, + }); + }, []); + const onHide = useCallback(() => { setEdge(undefined); }, []); @@ -140,6 +159,7 @@ export const useContextMenuConnectionHandlers = () => { onChangeMassState, onChangeShipSizeStatus, onToggleMassSave, + onToggleLoop, onHide, }; }; diff --git a/assets/js/hooks/Mapper/components/map/components/LocalCounter/LocalCounter.module.scss b/assets/js/hooks/Mapper/components/map/components/LocalCounter/LocalCounter.module.scss index 96e82d06e..fd580f209 100644 --- a/assets/js/hooks/Mapper/components/map/components/LocalCounter/LocalCounter.module.scss +++ b/assets/js/hooks/Mapper/components/map/components/LocalCounter/LocalCounter.module.scss @@ -52,3 +52,16 @@ } } } + +.Zoo { + .localCounter { + @-moz-document url-prefix() { + top: -1px; + } + + & > span { + position: relative; + top: -1px; + } + } +} diff --git a/assets/js/hooks/Mapper/components/map/components/LocalCounter/LocalCounter.tsx b/assets/js/hooks/Mapper/components/map/components/LocalCounter/LocalCounter.tsx index c0199603d..b7b5475c9 100644 --- a/assets/js/hooks/Mapper/components/map/components/LocalCounter/LocalCounter.tsx +++ b/assets/js/hooks/Mapper/components/map/components/LocalCounter/LocalCounter.tsx @@ -56,6 +56,7 @@ export const LocalCounter = ({ classes.TooltipActive, { [classes.Pathfinder]: theme === AvailableThemes.pathfinder, + [classes.Zoo]: theme === AvailableThemes.zoo, }, className, )} diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemEdge/SolarSystemEdge.module.scss b/assets/js/hooks/Mapper/components/map/components/SolarSystemEdge/SolarSystemEdge.module.scss index 54e12b889..53aa3bc2d 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemEdge/SolarSystemEdge.module.scss +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemEdge/SolarSystemEdge.module.scss @@ -68,6 +68,19 @@ stroke-dasharray: var(--rf-edge-bridge-dasharray, 10 5); stroke-linecap: var(--rf-edge-bridge-linecap, round); } + + &.Loop { + stroke: var(--rf-edge-loop, #80a5c5); + stroke-dasharray: var(--rf-edge-loop-dasharray, 5, 5); + z-index: -1; + } + + &.RallyRoute { + stroke: var(--rf-edge-rally, #00d4ff); + stroke-width: var(--rf-edge-rally-width, 5px); + filter: drop-shadow(0 0 3px var(--rf-edge-rally-glow, rgba(0, 212, 255, 0.5))); + animation: pulse-rally 2s ease-in-out infinite; + } } .EdgePathFront { @@ -97,6 +110,12 @@ stroke: var(--rf-edge-gate-inner, #1c1e15); } + &.Loop { + stroke: var(--rf-edge-loop-inner, #2c3844); + stroke-dasharray: var(--rf-edge-loop-dasharray, 5, 5); + z-index: -1; + } + &.Hovered { stroke: var(--rf-edge-mass-hover, #4e5d6c); stroke-width: var(--rf-edge-mass-normal-width, 2px); @@ -121,6 +140,11 @@ stroke-width: var(--rf-edge-mass-normal-thick-width, 3px); } } + + &.RallyRoute { + stroke: #0099cc; + stroke-width: 3px; + } } .ClickPath { @@ -161,3 +185,15 @@ height: var(--rf-edge-label-icon-size, 8px); font-size: var(--rf-edge-label-icon-font-size, 8px); } + +@keyframes pulse-rally { + 0% { + opacity: 0.8; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.8; + } +} diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemEdge/SolarSystemEdge.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemEdge/SolarSystemEdge.tsx index 664272517..6cf02f3e0 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemEdge/SolarSystemEdge.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemEdge/SolarSystemEdge.tsx @@ -10,6 +10,7 @@ import { WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrap import { useMapState } from '@/hooks/Mapper/components/map/MapProvider.tsx'; import { SHIP_SIZES_DESCRIPTION, SHIP_SIZES_NAMES_SHORT } from '@/hooks/Mapper/components/map/constants.ts'; import { TooltipPosition } from '@/hooks/Mapper/components/ui-kit'; +import { useRallyRoute } from '@/hooks/Mapper/hooks/useRallyRoute'; const MAP_TRANSLATES: Record = { [Position.Top]: 'translate(-48%, 0%)', @@ -46,12 +47,19 @@ export const SolarSystemEdge = ({ id, source, target, markerEnd, style, data }: const isWormhole = data?.type === ConnectionType.wormhole; const isGate = data?.type === ConnectionType.gate; const isBridge = data?.type === ConnectionType.bridge; + const isLoop = data?.type === ConnectionType.loop; + const isWormholeLike = isWormhole || isLoop; const { data: { isThickConnections }, } = useMapState(); const [hovered, setHovered] = useState(false); + + // Check if this edge is part of the rally route + const { highlightedConnections, isActive } = useRallyRoute(); + const connectionId = [source, target].sort().join('-'); + const isRallyRoute = isActive && highlightedConnections.has(connectionId); const [path, labelX, labelY, sx, sy, tx, ty, sourcePos, targetPos] = useMemo(() => { const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(sourceNode!, targetNode!); @@ -80,11 +88,13 @@ export const SolarSystemEdge = ({ id, source, target, markerEnd, style, data }: id={`back_${id}`} className={clsx(classes.EdgePathBack, { [classes.Tick]: isThickConnections, - [classes.time1]: isWormhole && data.time_status === TimeStatus._1h, - [classes.time4]: isWormhole && data.time_status === TimeStatus._4h, + [classes.time1]: isWormholeLike && data.time_status === TimeStatus._1h, + [classes.time4]: isWormholeLike && data.time_status === TimeStatus._4h, [classes.Hovered]: hovered, [classes.Gate]: isGate, [classes.Bridge]: isBridge, + [classes.Loop]: isLoop, + [classes.RallyRoute]: isRallyRoute, })} d={path} markerEnd={markerEnd} @@ -97,9 +107,11 @@ export const SolarSystemEdge = ({ id, source, target, markerEnd, style, data }: [classes.Hovered]: hovered, [classes.MassVerge]: isWormhole && data.mass_status === MassState.verge, [classes.MassHalf]: isWormhole && data.mass_status === MassState.half, - [classes.Frigate]: isWormhole && data.ship_size_type === ShipSizeStatus.small, + [classes.Frigate]: isWormholeLike && data.ship_size_type === ShipSizeStatus.small, [classes.Gate]: isGate, [classes.Bridge]: isBridge, + [classes.Loop]: isLoop, + [classes.RallyRoute]: isRallyRoute, })} d={path} markerEnd={markerEnd} diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.module.scss b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.module.scss index 6dcb3e485..08d9dfd88 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.module.scss +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.module.scss @@ -11,6 +11,18 @@ } } +@keyframes pulse-rally-node { + 0% { + box-shadow: 0 0 8px rgba(0, 212, 255, 0.6), inset 0 0 8px rgba(0, 212, 255, 0.2); + } + 50% { + box-shadow: 0 0 12px rgba(0, 212, 255, 0.8), inset 0 0 12px rgba(0, 212, 255, 0.3); + } + 100% { + box-shadow: 0 0 8px rgba(0, 212, 255, 0.6), inset 0 0 8px rgba(0, 212, 255, 0.2); + } +} + .RootCustomNode { display: flex; width: var(--rf-node-width, 130px); @@ -126,6 +138,12 @@ } } + &.rallyRoute { + border: 2px solid #00d4ff; + box-shadow: 0 0 8px rgba(0, 212, 255, 0.6), inset 0 0 8px rgba(0, 212, 255, 0.2); + animation: pulse-rally-node 2s ease-in-out infinite; + } + &.eve-system-status-home { border: 1px solid var(--eve-solar-system-status-color-home-dark30); background-image: linear-gradient(45deg, var(--eve-solar-system-status-color-background), transparent); diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx index 52924c6e5..aaa5e8418 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeDefault.tsx @@ -18,6 +18,7 @@ import { Tag } from 'primereact/tag'; import { LocalCounter } from '@/hooks/Mapper/components/map/components/LocalCounter'; import { KillsCounter } from '@/hooks/Mapper/components/map/components/KillsCounter'; import { useLocalCounter } from '@/hooks/Mapper/components/hooks/useLocalCounter.ts'; +import { SyncIntelAction } from '@/hooks/Mapper/components/map/components/SyncIntelAction'; // let render = 0; export const SolarSystemNodeDefault = memo((props: NodeProps) => { @@ -79,6 +80,7 @@ export const SolarSystemNodeDefault = memo((props: NodeProps { [classes.selected]: nodeVars.selected, [classes.rally]: nodeVars.isRally, + [classes.rallyRoute]: nodeVars.isRallyRoute, }, )} onMouseDownCapture={e => nodeVars.dbClick(e)} @@ -156,6 +158,9 @@ export const SolarSystemNodeDefault = memo((props: NodeProps )} + {nodeVars.hasIntelSource && ( + + )}
      ) => { @@ -77,6 +78,7 @@ export const SolarSystemNodeTheme = memo((props: NodeProps) { [classes.selected]: nodeVars.selected, [classes.rally]: nodeVars.isRally, + [classes.rallyRoute]: nodeVars.isRallyRoute, }, )} onMouseDownCapture={e => nodeVars.dbClick(e)} @@ -141,6 +143,9 @@ export const SolarSystemNodeTheme = memo((props: NodeProps) {nodeVars.hubs.includes(nodeVars.solarSystemId) && ( )} + {nodeVars.hasIntelSource && ( + + )} 9 + &.countAbove9 { + margin-right: 0.5rem; + } + } +} diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeZoo.tsx b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeZoo.tsx new file mode 100644 index 000000000..ba9841c93 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeZoo.tsx @@ -0,0 +1,365 @@ +import React, { memo, useCallback } from 'react'; +import { MapSolarSystemType } from '../../map.types'; +import { Handle, Position, NodeProps, ReactFlowState, useStore } from 'reactflow'; +import clsx from 'clsx'; +import classes from './SolarSystemNodeZoo.module.scss'; +import { PrimeIcons } from 'primereact/api'; +import { GiConcentrationOrb } from 'react-icons/gi'; +import { useSolarSystemNode, useNodeKillsCount } from '../../hooks'; +import { useLocalCounter } from '@/hooks/Mapper/components/hooks/useLocalCounter.ts'; +import { SyncIntelAction } from '@/hooks/Mapper/components/map/components/SyncIntelAction'; + +import { + useZooNames, + useZooLabels, + useSignatureAge, + useNodeSignatures, + useNodeOwnerTicker, +} from '../../hooks/useZooLogic'; +import { + MARKER_BOOKMARK_BG_STYLES, + STATUS_CLASSES, + EFFECT_BACKGROUND_STYLES, + LABEL_ICON_MAP, +} from '@/hooks/Mapper/components/map/constants'; +import { WormholeClassComp } from '@/hooks/Mapper/components/map/components/WormholeClassComp'; +import { formatSignatureAge } from '@/hooks/Mapper/components/map/helpers/signatureAge'; +import { KillsCounter } from '../KillsCounter/KillsCounter'; +import { LocalCounter } from '../LocalCounter/LocalCounter'; +import { TooltipSize } from '@/hooks/Mapper/components/ui-kit/WdTooltipWrapper/utils'; + +export const SolarSystemNodeZoo = memo((props: NodeProps) => { + const nodeVars = useSolarSystemNode(props); + + const updatedSignatures = useNodeSignatures(nodeVars.solarSystemId); + + const { killsCount: localKillsCount, killsActivityType: localKillsActivityType } = useNodeKillsCount( + nodeVars.solarSystemId, + ); + // A store selector rather than `useEdges()`. Both subscribe, so both keep + // `connectionCount` fresh - `useReactFlow().getEdges()` does not, which is + // why it was replaced. The difference is what re-renders: `useEdges()` + // returns a new array identity on every edge-store change, so dragging one + // connection re-rendered every node on the map and each one re-ran this + // filter plus its whole hook chain. Returning a number instead means zustand + // only re-renders this node when its own count actually changes. + const connectionCount = useStore( + useCallback( + (state: ReactFlowState) => + state.edges.reduce( + (count, edge) => (edge.source === props.id || edge.target === props.id ? count + 1 : count), + 0, + ), + [props.id], + ), + ); + + const showHandlers = nodeVars.isConnecting || nodeVars.hoverNodeId === nodeVars.id; + const dropHandler = nodeVars.isConnecting ? 'all' : 'none'; + + const { unsplashedCount } = useZooLabels(connectionCount, updatedSignatures); + + const { data } = props; + const { owner_id, owner_type, owner_ticker } = data; + + const { ownerTicker, ownerURL } = useNodeOwnerTicker(owner_id, owner_type, owner_ticker); + + const { systemName, customLabel, customName } = useZooNames( + { + temporaryName: nodeVars.temporaryName, + solarSystemName: nodeVars.solarSystemName, + regionName: nodeVars.regionName, + labelCustom: nodeVars.labelCustom, + ownerTicker: ownerTicker, + isWormhole: nodeVars.isWormhole, + }, + props, + ); + + const { signatureAgeHours, bookmarkColor } = useSignatureAge(updatedSignatures); + + const { localCounterCharacters } = useLocalCounter(nodeVars); + + return ( + <> + {nodeVars.visible && ( +
      + {customLabel !== '' && ( +
      + {ownerURL && ownerTicker ? ( + + {customLabel} + + ) : ( + {customLabel} + )} +
      + )} + + {localKillsCount && localKillsCount > 0 && nodeVars.solarSystemId && localKillsActivityType && ( + +
      + + {localKillsCount} +
      +
      + )} + + {unsplashedCount > 0 && ( +
      + + + {unsplashedCount} + +
      + )} + + {signatureAgeHours >= 0 && ( +
      + + {formatSignatureAge(signatureAgeHours)} + +
      + )} + + {nodeVars.labelsInfo.map(x => { + const iconData = LABEL_ICON_MAP[x.id]; + return ( +
      + {iconData ? ( + React.isValidElement(iconData.icon) ? ( + {iconData.icon} + ) : ( + + ) + ) : ( + {x.shortName} + )} +
      + ); + })} +
      + )} +
      nodeVars.dbClick(e)} + > + {nodeVars.visible && ( + <> +
      +
      + {nodeVars.classTitle ?? '-'} +
      + +
      + {systemName} +
      + + {nodeVars.isWormhole && ( +
      +
      + {nodeVars.sortedStatics.map(whClass => ( + + ))} +
      + {nodeVars.effectName !== null && ( +
      + )} +
      + )} +
      + +
      +
      + {nodeVars.isShattered && ( +
      + +
      + )} + {nodeVars.tag != null && nodeVars.tag !== '' && ( +
      {`[${nodeVars.tag}]`}
      + )} +
      + {customName} +
      +
      +
      +
      0, + [classes.countAbove9]: nodeVars.charactersInSystem.length > 9, + })} + > + {nodeVars.locked && } + {nodeVars.hubs.includes(nodeVars.solarSystemId.toString()) && ( + + )} + {nodeVars.hasIntelSource && } +
      + +
      +
      + + )} +
      + + {nodeVars.systemHighlighted === nodeVars.solarSystemId && ( +
      +
      +
      +
      +
      +
      + )} + +
      + + { + e.stopPropagation(); + nodeVars.dbClick(e); + }} + /> + + + +
      + + ); +}); + +SolarSystemNodeZoo.displayName = 'SolarSystemNodeZoo'; +export default SolarSystemNodeZoo; diff --git a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/index.ts b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/index.ts index 3b83f19cd..5a875f8ec 100644 --- a/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/index.ts +++ b/assets/js/hooks/Mapper/components/map/components/SolarSystemNode/index.ts @@ -1,2 +1,3 @@ export * from './SolarSystemNodeDefault'; export * from './SolarSystemNodeTheme'; +export * from './SolarSystemNodeZoo'; diff --git a/assets/js/hooks/Mapper/components/map/components/SyncIntelAction.tsx b/assets/js/hooks/Mapper/components/map/components/SyncIntelAction.tsx new file mode 100644 index 000000000..f03651023 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/SyncIntelAction.tsx @@ -0,0 +1,36 @@ +import clsx from 'clsx'; +import { TooltipPosition, WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit'; +import { OutCommand } from '@/hooks/Mapper/types'; +import { useMapState } from '@/hooks/Mapper/components/map/MapProvider'; + +interface SyncIntelActionProps { + solarSystemId: string; +} + +export const SyncIntelAction = ({ solarSystemId }: SyncIntelActionProps) => { + const { outCommand } = useMapState(); + + return ( + +
      + } + smallPaddings + > +
      + + + {isEOL && } + {isCrit && } + +
      + + ); +}; diff --git a/assets/js/hooks/Mapper/components/map/components/WormholeClassComp/WormholeClassComp.tsx b/assets/js/hooks/Mapper/components/map/components/WormholeClassComp/WormholeClassComp.tsx index 6cc62b798..1e124ab98 100644 --- a/assets/js/hooks/Mapper/components/map/components/WormholeClassComp/WormholeClassComp.tsx +++ b/assets/js/hooks/Mapper/components/map/components/WormholeClassComp/WormholeClassComp.tsx @@ -1,9 +1,9 @@ import { useMapState } from '@/hooks/Mapper/components/map/MapProvider.tsx'; -import { WORMHOLE_CLASS_STYLES, WORMHOLES_ADDITIONAL_INFO } from '@/hooks/Mapper/components/map/constants.ts'; +import { WORMHOLE_CLASS_STYLES, WORMHOLES_ADDITIONAL_INFO } from '@/hooks/Mapper/components/map/constants'; import clsx from 'clsx'; interface WormholeClassComp { - id: string; + id: string | number; } export const WormholeClassComp = ({ id }: WormholeClassComp) => { const { diff --git a/assets/js/hooks/Mapper/components/map/components/ZooIcons/Flygd.tsx b/assets/js/hooks/Mapper/components/map/components/ZooIcons/Flygd.tsx new file mode 100644 index 000000000..f9bf321f6 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/ZooIcons/Flygd.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { JSX } from 'react/jsx-runtime'; +export const FlyGdIcon = (props: JSX.IntrinsicAttributes & React.SVGProps) => ( + + + +); diff --git a/assets/js/hooks/Mapper/components/map/components/ZooIcons/Monke.tsx b/assets/js/hooks/Mapper/components/map/components/ZooIcons/Monke.tsx new file mode 100644 index 000000000..02312118b --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/ZooIcons/Monke.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { JSX } from 'react/jsx-runtime'; +export const MonkeIcon = (props: JSX.IntrinsicAttributes & React.SVGProps) => ( + + + +); diff --git a/assets/js/hooks/Mapper/components/map/components/ZooIcons/Wormhole.tsx b/assets/js/hooks/Mapper/components/map/components/ZooIcons/Wormhole.tsx new file mode 100644 index 000000000..6924b966b --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/ZooIcons/Wormhole.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { JSX } from 'react/jsx-runtime'; +export const WormHoleIcon = (props: JSX.IntrinsicAttributes & React.SVGProps) => ( + + + +); diff --git a/assets/js/hooks/Mapper/components/map/components/ZooIcons/flygd.svg b/assets/js/hooks/Mapper/components/map/components/ZooIcons/flygd.svg new file mode 100755 index 000000000..586d1e3cf --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/ZooIcons/flygd.svg @@ -0,0 +1,17 @@ + + + + diff --git a/assets/js/hooks/Mapper/components/map/components/ZooIcons/index.ts b/assets/js/hooks/Mapper/components/map/components/ZooIcons/index.ts new file mode 100644 index 000000000..953d85077 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/ZooIcons/index.ts @@ -0,0 +1,3 @@ +export * from './Flygd'; +export * from './Wormhole'; +export * from './Monke'; diff --git a/assets/js/hooks/Mapper/components/map/components/ZooIcons/monke.svg b/assets/js/hooks/Mapper/components/map/components/ZooIcons/monke.svg new file mode 100755 index 000000000..fd687b437 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/ZooIcons/monke.svg @@ -0,0 +1,17 @@ + + + + diff --git a/assets/js/hooks/Mapper/components/map/components/ZooIcons/wormhole.svg b/assets/js/hooks/Mapper/components/map/components/ZooIcons/wormhole.svg new file mode 100755 index 000000000..a29260b95 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/components/ZooIcons/wormhole.svg @@ -0,0 +1,17 @@ + + + + diff --git a/assets/js/hooks/Mapper/components/map/constants.ts b/assets/js/hooks/Mapper/components/map/constants.ts index 1e64f5fee..5264a1b41 100644 --- a/assets/js/hooks/Mapper/components/map/constants.ts +++ b/assets/js/hooks/Mapper/components/map/constants.ts @@ -1,4 +1,9 @@ -import { ConnectionType, MassState, ShipSizeStatus } from '@/hooks/Mapper/types'; +import { ConnectionType, MassState, ShipSizeStatus } from '../../types/connection'; +import { LABEL_ICON_MAP, LABELS, LABELS_INFO, LABELS_ORDER } from './labelIconMap'; +import { ZOO_BOOKMARK_STYLES, ZOO_TEXT_STYLES } from './zooConstants'; +export type { LabelIcon, LabelInfo } from './labelIconMap'; +export { LABEL_ICON_MAP, LABELS, LABELS_INFO, LABELS_ORDER }; +export { ZOO_BOOKMARK_STYLES, ZOO_TEXT_STYLES } from './zooConstants'; export enum SOLAR_SYSTEM_CLASS_IDS { ccp1 = -1, @@ -634,7 +639,10 @@ export const EFFECT_BACKGROUND_STYLES: Record = { 'Federal Stellar Observatory': 'eve-wh-effect-color-federalStellarObservatory', }; +// Marker bookmark background styles +// Upstream styles are defined here, zoo-specific styles are merged from zooConstants.ts export const MARKER_BOOKMARK_BG_STYLES: Record = { + // Upstream styles custom: 'wd-marker-bookmark-color-custom', shattered: 'wd-marker-bookmark-color-shattered', a0: 'wd-marker-bookmark-color-a0', @@ -642,33 +650,14 @@ export const MARKER_BOOKMARK_BG_STYLES: Record = { activityWarn: 'wd-marker-bookmark-color-warn', activityDanger: 'wd-marker-bookmark-color-danger', - la: 'wd-marker-bookmark-color-average', - lb: 'wd-marker-bookmark-color-ytirium', + // Upstream label styles lc: 'wd-marker-bookmark-color-ytirium', l1: 'wd-marker-bookmark-color-l1', l2: 'wd-marker-bookmark-color-l2', l3: 'wd-marker-bookmark-color-l3', -}; - -export enum LABELS { - clear = 'clear', - la = 'a', - lb = 'b', - lc = 'c', - l1 = '1', - l2 = '2', - l3 = '3', -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const LABELS_INFO: Record = { - [LABELS.clear]: { id: 'clear', name: 'Clear', shortName: '', icon: '' }, - [LABELS.la]: { id: 'la', name: 'Label A', shortName: 'A', icon: '' }, - [LABELS.lb]: { id: 'lb', name: 'Label B', shortName: 'B', icon: '' }, - [LABELS.lc]: { id: 'lc', name: 'Label C', shortName: 'C', icon: '' }, - [LABELS.l1]: { id: 'l1', name: 'Label 1', shortName: '1', icon: '' }, - [LABELS.l2]: { id: 'l2', name: 'Label 2', shortName: '2', icon: '' }, - [LABELS.l3]: { id: 'l3', name: 'Label 3', shortName: '3', icon: '' }, + // Zoo-specific styles (merged from zooConstants.ts) + ...ZOO_BOOKMARK_STYLES, }; export enum STATUSES { @@ -701,8 +690,6 @@ export const STATUSES_ORDER = [ STATUSES.dangerous, ]; -export const LABELS_ORDER = [LABELS.clear, LABELS.la, LABELS.lb, LABELS.lc, LABELS.l1, LABELS.l2, LABELS.l3]; - export const STATUS_COLOR_CLASSES: Record = { [STATUSES.unknown]: 'eve-system-status-color-clear', [STATUSES.home]: 'eve-system-status-color-home', @@ -723,12 +710,13 @@ export const STATUS_CLASSES: Record = { [STATUSES.dangerous]: 'eve-system-status-dangerous', }; -export const TYPE_NAMES_ORDER = [ConnectionType.wormhole, ConnectionType.gate, ConnectionType.bridge]; +export const TYPE_NAMES_ORDER = [ConnectionType.wormhole, ConnectionType.gate, ConnectionType.bridge, ConnectionType.loop]; export const TYPE_NAMES = { [ConnectionType.wormhole]: 'Wormhole', [ConnectionType.gate]: 'Gate', [ConnectionType.bridge]: 'Jumpgate', + [ConnectionType.loop]: 'Loop', }; export const MASS_STATE_NAMES_ORDER = [MassState.verge, MassState.half, MassState.normal]; @@ -754,6 +742,7 @@ export const SHIP_SIZES_NAMES = { [ShipSizeStatus.freight]: 'Huge', [ShipSizeStatus.capital]: 'Capital', }; + export const SHIP_SIZES_SIZE = { [ShipSizeStatus.small]: '5K', [ShipSizeStatus.medium]: '62K', diff --git a/assets/js/hooks/Mapper/components/map/helpers/getBackgroundClass.ts b/assets/js/hooks/Mapper/components/map/helpers/getBackgroundClass.ts index 845b2bfce..62ec064de 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/getBackgroundClass.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/getBackgroundClass.ts @@ -3,7 +3,7 @@ import { SECURITY_BACKGROUND_CLASSES, SYSTEM_CLASS_BACKGROUND_CLASSES, WORMHOLE_CLASS_BACKGROUND_CLASSES, -} from '@/hooks/Mapper/components/map/constants.ts'; +} from '@/hooks/Mapper/components/map/constants'; import { isKnownSpace } from '@/hooks/Mapper/components/map/helpers/isKnownSpace.ts'; import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace.ts'; diff --git a/assets/js/hooks/Mapper/components/map/helpers/getSystemClassStyles.ts b/assets/js/hooks/Mapper/components/map/helpers/getSystemClassStyles.ts index 4a2bc4b4f..9ca70cb78 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/getSystemClassStyles.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/getSystemClassStyles.ts @@ -3,7 +3,7 @@ import { SECURITY_FOREGROUND_CLASSES, SYSTEM_CLASS_STYLES, WORMHOLE_CLASS_STYLES, -} from '@/hooks/Mapper/components/map/constants.ts'; +} from '@/hooks/Mapper/components/map/constants'; import { isWormholeSpace } from '@/hooks/Mapper/components/map/helpers/isWormholeSpace.ts'; import { SolarSystemStaticInfo } from '@/hooks/Mapper/types'; @@ -20,4 +20,3 @@ export const getSystemClassStyles = ({ systemClass, security }: SystemClassStyle return SYSTEM_CLASS_STYLES[systemClass]; }; - diff --git a/assets/js/hooks/Mapper/components/map/helpers/getThemeBehavior.ts b/assets/js/hooks/Mapper/components/map/helpers/getThemeBehavior.ts index 3cc8f9831..9a48b002f 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/getThemeBehavior.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/getThemeBehavior.ts @@ -1,4 +1,4 @@ -import { SolarSystemNodeDefault, SolarSystemNodeTheme } from '../components/SolarSystemNode'; +import { SolarSystemNodeDefault, SolarSystemNodeTheme, SolarSystemNodeZoo } from '../components/SolarSystemNode'; import type { NodeProps } from 'reactflow'; import type { ComponentType } from 'react'; import { MapSolarSystemType } from '../map.types'; @@ -6,27 +6,48 @@ import { ConnectionMode } from 'reactflow'; export type SolarSystemNodeComponent = ComponentType>; +export interface CustomTags { + letters?: string[]; + digits?: string[]; + others?: string[]; +} + interface ThemeBehavior { isPanAndDrag: boolean; nodeComponent: SolarSystemNodeComponent; connectionMode: ConnectionMode; + customTags?: CustomTags; } -const THEME_BEHAVIORS: { - [key: string]: ThemeBehavior; -} = { +const THEME_BEHAVIORS: { [key: string]: ThemeBehavior } = { default: { isPanAndDrag: false, nodeComponent: SolarSystemNodeDefault, connectionMode: ConnectionMode.Loose, + customTags: { + letters: ['A', 'B', 'C', 'D', 'E', 'F', 'X', 'Y', 'Z'], + digits: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], + }, }, pathfinder: { isPanAndDrag: true, nodeComponent: SolarSystemNodeTheme, connectionMode: ConnectionMode.Loose, }, + zoo: { + isPanAndDrag: true, + nodeComponent: SolarSystemNodeZoo, + connectionMode: ConnectionMode.Strict, + customTags: { + others: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10+', '20+', 'Baiting'], + }, + }, }; -export function getBehaviorForTheme(themeName: string) { +export function getBehaviorForTheme(themeName: string): ThemeBehavior { return THEME_BEHAVIORS[themeName] ?? THEME_BEHAVIORS.default; } + +export function getCustomTagsForTheme(themeName: string): CustomTags { + return getBehaviorForTheme(themeName).customTags ?? {}; +} diff --git a/assets/js/hooks/Mapper/components/map/helpers/isKnownSpace.ts b/assets/js/hooks/Mapper/components/map/helpers/isKnownSpace.ts index 4b4dc6faf..7c310c7f6 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/isKnownSpace.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/isKnownSpace.ts @@ -1,4 +1,4 @@ -import { SOLAR_SYSTEM_CLASS_IDS } from '@/hooks/Mapper/components/map/constants.ts'; +import { SOLAR_SYSTEM_CLASS_IDS } from '@/hooks/Mapper/components/map/constants'; export const isKnownSpace = (wormholeClassID: number) => { switch (wormholeClassID) { diff --git a/assets/js/hooks/Mapper/components/map/helpers/isPochvenSpace.ts b/assets/js/hooks/Mapper/components/map/helpers/isPochvenSpace.ts index c4e52afa8..63ecfb7be 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/isPochvenSpace.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/isPochvenSpace.ts @@ -1,4 +1,4 @@ -import { SOLAR_SYSTEM_CLASS_IDS } from '@/hooks/Mapper/components/map/constants.ts'; +import { SOLAR_SYSTEM_CLASS_IDS } from '@/hooks/Mapper/components/map/constants'; export const isPochvenSpace = (wormholeClassID: number) => { switch (wormholeClassID) { diff --git a/assets/js/hooks/Mapper/components/map/helpers/isWormholeSpace.ts b/assets/js/hooks/Mapper/components/map/helpers/isWormholeSpace.ts index 50688956c..45147df23 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/isWormholeSpace.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/isWormholeSpace.ts @@ -1,4 +1,4 @@ -import { SOLAR_SYSTEM_CLASS_IDS } from '@/hooks/Mapper/components/map/constants.ts'; +import { SOLAR_SYSTEM_CLASS_IDS } from '@/hooks/Mapper/components/map/constants'; export const isWormholeSpace = (wormholeClassID: number) => { switch (wormholeClassID) { diff --git a/assets/js/hooks/Mapper/components/map/helpers/isZarzakhSpace.ts b/assets/js/hooks/Mapper/components/map/helpers/isZarzakhSpace.ts index 70051bc62..64333b6ce 100644 --- a/assets/js/hooks/Mapper/components/map/helpers/isZarzakhSpace.ts +++ b/assets/js/hooks/Mapper/components/map/helpers/isZarzakhSpace.ts @@ -1,4 +1,4 @@ -import { SOLAR_SYSTEM_CLASS_IDS } from '@/hooks/Mapper/components/map/constants.ts'; +import { SOLAR_SYSTEM_CLASS_IDS } from '@/hooks/Mapper/components/map/constants'; export const isZarzakhSpace = (wormholeClassID: number) => { switch (wormholeClassID) { diff --git a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts new file mode 100644 index 000000000..08be35263 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.test.ts @@ -0,0 +1,144 @@ +import { SignatureGroup, SignatureKind, SystemSignature } from '@/hooks/Mapper/types/signatures'; +import { computeSignatureAge, formatSignatureAge, getSignatureAgeColor, SIGNATURE_AGE_COLORS } from './signatureAge'; + +const HOUR = 1000 * 60 * 60; +const NOW = Date.parse('2026-08-09T12:00:00Z'); + +const sig = (overrides: Partial = {}): SystemSignature => ({ + eve_id: 'ABC-123', + kind: SignatureKind.CosmicSignature, + name: '', + group: SignatureGroup.CosmicSignature, + type: '', + ...overrides, +}); + +const hoursAgo = (h: number) => new Date(NOW - h * HOUR).toISOString(); + +describe('computeSignatureAge', () => { + it('reports no age when the system has no signatures at all', () => { + expect(computeSignatureAge([], NOW).signatureAgeHours).toBe(-1); + expect(computeSignatureAge(null, NOW).signatureAgeHours).toBe(-1); + }); + + // The reported bug: signatures pasted straight from the probe scanner have no + // resolved group, and were filtered out entirely, so a freshly scanned system + // showed no age until at least one signature resolved to a wormhole. + it('counts unscanned signatures whose group is still Cosmic Signature', () => { + const sigs = [sig({ group: SignatureGroup.CosmicSignature, updated_at: hoursAgo(2) })]; + + expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(2); + }); + + it('counts non-wormhole site signatures', () => { + const sigs = [sig({ group: SignatureGroup.CombatSite, name: 'Perimeter Ambush Point', updated_at: hoursAgo(5) })]; + + expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(5); + }); + + it('counts wormhole signatures already linked to a mapped system', () => { + const sigs = [ + sig({ + group: SignatureGroup.Wormhole, + updated_at: hoursAgo(3), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + linked_system: { solar_system_id: 31000001 } as any, + }), + ]; + + expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(3); + }); + + it('uses the newest timestamp across all signatures', () => { + const sigs = [ + sig({ eve_id: 'AAA-111', updated_at: hoursAgo(9) }), + sig({ eve_id: 'BBB-222', updated_at: hoursAgo(1) }), + sig({ eve_id: 'CCC-333', updated_at: hoursAgo(6) }), + ]; + + const { signatureAgeHours, newestUpdatedAt } = computeSignatureAge(sigs, NOW); + + expect(signatureAgeHours).toBe(1); + expect(newestUpdatedAt).toBe(NOW - HOUR); + }); + + it('falls back to inserted_at when a signature has never been updated', () => { + const sigs = [sig({ inserted_at: hoursAgo(7) })]; + + expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(7); + }); + + // An unparseable updated_at yields NaN, and `NaN > max` is false, so the + // signature used to collapse to 0 and take its perfectly good inserted_at + // down with it. + it('falls back to inserted_at when updated_at will not parse', () => { + const sigs = [sig({ updated_at: 'not-a-date', inserted_at: hoursAgo(7) })]; + + const { signatureAgeHours, newestUpdatedAt } = computeSignatureAge(sigs, NOW); + + expect(signatureAgeHours).toBe(7); + expect(newestUpdatedAt).toBe(NOW - 7 * HOUR); + }); + + it('reports no age when neither timestamp will parse', () => { + const sigs = [sig({ updated_at: 'not-a-date', inserted_at: 'also-not-a-date' })]; + + expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(-1); + }); + + it('ignores a signature with unparseable timestamps without losing its neighbours', () => { + const sigs = [ + sig({ eve_id: 'AAA-111', updated_at: 'not-a-date' }), + sig({ eve_id: 'BBB-222', updated_at: hoursAgo(2) }), + ]; + + expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(2); + }); + + it('reports no age when signatures exist but carry no usable timestamp', () => { + expect(computeSignatureAge([sig()], NOW).signatureAgeHours).toBe(-1); + }); + + it('never reports a negative age for a clock skewed into the future', () => { + const sigs = [sig({ updated_at: new Date(NOW + 2 * HOUR).toISOString() })]; + + expect(computeSignatureAge(sigs, NOW).signatureAgeHours).toBe(0); + }); + + // The 12h cliff used to reuse -1 to mean "too old to display", which made a + // stale system indistinguishable from one that was never scanned. + it('keeps reporting an age well past twelve hours', () => { + const { signatureAgeHours, bookmarkColor } = computeSignatureAge([sig({ updated_at: hoursAgo(72) })], NOW); + + expect(signatureAgeHours).toBe(72); + expect(bookmarkColor).toBe(SIGNATURE_AGE_COLORS.ancient); + }); +}); + +describe('getSignatureAgeColor', () => { + it.each([ + [0, SIGNATURE_AGE_COLORS.fresh], + [3, SIGNATURE_AGE_COLORS.fresh], + [4, SIGNATURE_AGE_COLORS.aging], + [8, SIGNATURE_AGE_COLORS.aging], + [9, SIGNATURE_AGE_COLORS.stale], + [12, SIGNATURE_AGE_COLORS.stale], + [13, SIGNATURE_AGE_COLORS.ancient], + [500, SIGNATURE_AGE_COLORS.ancient], + ])('maps %ih to the expected colour', (hours, expected) => { + expect(getSignatureAgeColor(hours)).toBe(expected); + }); +}); + +describe('formatSignatureAge', () => { + it('shows hours below a day', () => { + expect(formatSignatureAge(0)).toBe('0h'); + expect(formatSignatureAge(23)).toBe('23h'); + }); + + it('switches to whole days at twenty-four hours so the bookmark stays narrow', () => { + expect(formatSignatureAge(24)).toBe('1d'); + expect(formatSignatureAge(47)).toBe('1d'); + expect(formatSignatureAge(72)).toBe('3d'); + }); +}); diff --git a/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts new file mode 100644 index 000000000..ee4487a06 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/helpers/signatureAge.ts @@ -0,0 +1,108 @@ +import type { SystemSignature } from '@/hooks/Mapper/types/signatures'; + +/** + * Bookmark colours for the "time since this system was scanned" indicator. + * + * `ancient` has no upper bound on purpose. An age that stops rendering past + * some threshold makes a long-neglected system look identical to one nobody + * has ever scanned, which is exactly the distinction the indicator exists to + * draw. Absence of the bookmark means "never scanned", and nothing else. + */ +export const SIGNATURE_AGE_COLORS = { + fresh: '#388E3C', + aging: '#E65100', + stale: '#B71C1C', + ancient: '#4A148C', +} as const; + +/** Age, in hours, at which the label switches from hours to whole days. */ +const DAY_HOURS = 24; + +export type SignatureAge = { + /** Epoch millis of the most recent signature timestamp, or 0 if there is none. */ + newestUpdatedAt: number; + /** Whole hours since that timestamp, or -1 when the system has never been scanned. */ + signatureAgeHours: number; + bookmarkColor: string; +}; + +export function getSignatureAgeColor(signatureAgeHours: number): string { + if (signatureAgeHours < 4) { + return SIGNATURE_AGE_COLORS.fresh; + } + if (signatureAgeHours <= 8) { + return SIGNATURE_AGE_COLORS.aging; + } + if (signatureAgeHours <= 12) { + return SIGNATURE_AGE_COLORS.stale; + } + return SIGNATURE_AGE_COLORS.ancient; +} + +/** + * Renders an age for the bookmark, keeping it to a couple of characters so the + * marker width stays stable as a system goes stale. + */ +export function formatSignatureAge(signatureAgeHours: number): string { + if (signatureAgeHours < DAY_HOURS) { + return `${signatureAgeHours}h`; + } + return `${Math.floor(signatureAgeHours / DAY_HOURS)}d`; +} + +/** + * Parses a signature timestamp, treating anything unparseable as absent. + * + * `new Date('garbage').getTime()` is NaN, and NaN loses every `>` comparison, + * so an unparseable value would otherwise be indistinguishable from "no + * timestamp" *and* would suppress the fallback below it. + */ +function parseTimestamp(value?: string | null): number { + if (!value) { + return 0; + } + const ts = new Date(value).getTime(); + return Number.isFinite(ts) ? ts : 0; +} + +function getSignatureTimestamp(s: SystemSignature): number { + return parseTimestamp(s.updated_at) || parseTimestamp(s.inserted_at); +} + +/** + * Computes how long ago this system was last scanned. + * + * Every signature counts, whatever its group and whether or not it is linked to + * a mapped system: pasting the probe scanner window re-stamps every signature + * it contains (untouched rows are still sent as updates, see `getActualSigs`), + * so the newest timestamp in the system is the time of the last paste. An + * earlier version reused the `group === 'Wormhole' && !linked_system` predicate + * from `useUnsplashedSignatures`, which answers a different question โ€” "which + * wormholes are still unmapped" โ€” and so hid the indicator entirely for a + * system whose signatures were all still unscanned. + */ +export function computeSignatureAge(systemSigs: SystemSignature[] | null | undefined, now: number): SignatureAge { + const newestUpdatedAt = (systemSigs ?? []).reduce((max, s) => { + const ts = getSignatureTimestamp(s); + return ts > max ? ts : max; + }, 0); + + // No signature carries a usable timestamp, so there is nothing to age. A + // negative age is the signal to suppress the bookmark; every real age, however + // large, renders. + if (newestUpdatedAt === 0) { + return { + newestUpdatedAt: 0, + signatureAgeHours: -1, + bookmarkColor: SIGNATURE_AGE_COLORS.fresh, + }; + } + + const signatureAgeHours = Math.max(0, Math.round((now - newestUpdatedAt) / (1000 * 60 * 60))); + + return { + newestUpdatedAt, + signatureAgeHours, + bookmarkColor: getSignatureAgeColor(signatureAgeHours), + }; +} diff --git a/assets/js/hooks/Mapper/components/map/hooks/api/useCommandsCharacters.ts b/assets/js/hooks/Mapper/components/map/hooks/api/useCommandsCharacters.ts index 62ee50400..682978ccf 100644 --- a/assets/js/hooks/Mapper/components/map/hooks/api/useCommandsCharacters.ts +++ b/assets/js/hooks/Mapper/components/map/hooks/api/useCommandsCharacters.ts @@ -51,7 +51,10 @@ export const useCommandsCharacters = () => { const characterUpdated = useCallback((value: CommandCharacterUpdated) => { ref.current.update(state => { - return { characters: [...state.characters.filter(x => x.eve_id !== value.eve_id), value] }; + const existingCharacter = state.characters.find(x => x.eve_id === value.eve_id); + const updatedCharacter = + existingCharacter && value.ready === undefined ? { ...value, ready: existingCharacter.ready } : value; + return { characters: [...state.characters.filter(x => x.eve_id !== value.eve_id), updatedCharacter] }; }); }, []); diff --git a/assets/js/hooks/Mapper/components/map/hooks/useBackgroundVars.ts b/assets/js/hooks/Mapper/components/map/hooks/useBackgroundVars.ts index a204c09c9..ce7cdfc93 100644 --- a/assets/js/hooks/Mapper/components/map/hooks/useBackgroundVars.ts +++ b/assets/js/hooks/Mapper/components/map/hooks/useBackgroundVars.ts @@ -6,13 +6,13 @@ export function useBackgroundVars(themeName?: string) { const [gap, setGap] = useState(16); const [size, setSize] = useState(1); const [color, setColor] = useState('#81818b'); - const [snapSize, setSnapSize] = useState(25); + const [snapSizeX, setSnapSizeX] = useState(25); + const [snapSizeY, setSnapSizeY] = useState(25); + useEffect(() => { - // match any element whose entire `class` attribute ends with "-theme" let themeEl = document.querySelector('[class$="-theme"]'); - // If none is found, fall back to the element if (!themeEl) { themeEl = document.documentElement; } @@ -30,19 +30,27 @@ export function useBackgroundVars(themeName?: string) { const cssVarGap = style.getPropertyValue('--rf-bg-gap'); const cssVarSize = style.getPropertyValue('--rf-bg-size'); + // The per-axis variables are a zoo addition; `pathfinder-theme.scss` still + // only defines the single-value `--rf-snap-size`. Without this fallback that + // theme lost its configured 17 and silently snapped at the hardcoded 25. const cssVarSnapSize = style.getPropertyValue('--rf-snap-size'); + const cssVarSnapSizeX = style.getPropertyValue('--rf-snap-sizeX') || cssVarSnapSize; + const cssVarSnapSizeY = style.getPropertyValue('--rf-snap-sizeY') || cssVarSnapSize; + const cssColor = style.getPropertyValue('--rf-bg-pattern-color'); const gapNum = parseInt(cssVarGap, 10) || 16; const sizeNum = parseInt(cssVarSize, 10) || 1; - const snapSize = parseInt(cssVarSnapSize, 10) || 25; //react-flow default + const snapSizeX = parseInt(cssVarSnapSizeX, 10) || 25; + const snapSizeY = parseInt(cssVarSnapSizeY, 10) || 25; setVariant(finalVariant); setGap(gapNum); setSize(sizeNum); setColor(cssColor); - setSnapSize(snapSize); + setSnapSizeX(snapSizeX); + setSnapSizeY(snapSizeY); }, [themeName]); - return { variant, gap, size, color, snapSize }; + return { variant, gap, size, color, snapSizeX, snapSizeY }; } diff --git a/assets/js/hooks/Mapper/components/map/hooks/useSolarSystemNode.ts b/assets/js/hooks/Mapper/components/map/hooks/useSolarSystemNode.ts index 547f56291..b8b08578b 100644 --- a/assets/js/hooks/Mapper/components/map/hooks/useSolarSystemNode.ts +++ b/assets/js/hooks/Mapper/components/map/hooks/useSolarSystemNode.ts @@ -14,6 +14,7 @@ import { useUnsplashedSignatures } from './useUnsplashedSignatures'; import { useSystemName } from './useSystemName'; import { LabelInfo, useLabelsInfo } from './useLabelsInfo'; import { getSystemStaticInfo } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic'; +import { useRallyRoute } from '@/hooks/Mapper/hooks/useRallyRoute'; export interface SolarSystemNodeVars { id: string; @@ -54,6 +55,8 @@ export interface SolarSystemNodeVars { description: string | null; comments_count: number | null; systemHighlighted: string | undefined; + hasIntelSource: boolean; + isRallyRoute: boolean; } export const useSolarSystemNode = (props: NodeProps): SolarSystemNodeVars => { @@ -72,8 +75,10 @@ export const useSolarSystemNode = (props: NodeProps): SolarS } = data; const { - storedSettings: { interfaceSettings }, - data: { systemSignatures: mapSystemSignatures, pings }, + storedSettings: { + interfaceSettings: { isShowUnsplashedSignatures }, + }, + data: { systemSignatures: mapSystemSignatures, pings, options: mapOptions }, } = useMapRootState(); const systemStaticInfo = useMemo(() => { @@ -93,7 +98,6 @@ export const useSolarSystemNode = (props: NodeProps): SolarS constellation_name, } = systemStaticInfo; - const { isShowUnsplashedSignatures } = interfaceSettings; const isTempSystemNameEnabled = useMapGetOption('show_temp_system_name') === 'true'; const isShowLinkedSigId = useMapGetOption('show_linked_signature_id') === 'true'; const isShowLinkedSigIdTempName = useMapGetOption('show_linked_signature_id_temp_name') === 'true'; @@ -182,7 +186,11 @@ export const useSolarSystemNode = (props: NodeProps): SolarS return region_name; }, [constellation_name, region_id, region_name]); - const nodeVars: SolarSystemNodeVars = { + // Check if this system is part of the rally route + const { highlightedSystems, isActive: isRallyRouteActive } = useRallyRoute(); + const isRallyRoute = isRallyRouteActive && highlightedSystems.has(solar_system_id); + + return { id, selected, visible, @@ -221,7 +229,7 @@ export const useSolarSystemNode = (props: NodeProps): SolarS description, comments_count, systemHighlighted, + hasIntelSource: !!mapOptions?.intel_source_map_id, + isRallyRoute, }; - - return nodeVars; }; diff --git a/assets/js/hooks/Mapper/components/map/hooks/useZooLogic.ts b/assets/js/hooks/Mapper/components/map/hooks/useZooLogic.ts new file mode 100644 index 000000000..409c0e978 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/hooks/useZooLogic.ts @@ -0,0 +1,234 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { NodeProps } from 'reactflow'; +import { MapSolarSystemType } from '../map.types'; +import { Commands, OutCommand } from '@/hooks/Mapper/types/mapHandlers'; +import type { SystemSignature } from '@/hooks/Mapper/types/signatures'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { useMapEventListener } from '@/hooks/Mapper/events'; +import { useMapState } from '../MapProvider'; +import { computeSignatureAge } from '../helpers/signatureAge'; +import { useUnsplashedSignatures } from './useUnsplashedSignatures'; + +const zkillboardBaseURL = 'https://zkillboard.com'; + +/** + * Safely returns a string value or a fallback. + */ +function safeString(value?: string | null, fallback = ''): string { + return value ?? fallback; +} + +/** + * Custom hook to listen for signature update notifications. + * + * Since the event only carries a systemId notification, + * we simply call the onUpdate callback to trigger a refetch. + */ +function useSignatureUpdateListener(systemId: string, onUpdate: () => void): void { + useMapEventListener(({ name, data }) => { + if (name === Commands.signaturesUpdated && String(data) === String(systemId)) { + onUpdate(); + return true; + } + return false; + }); +} + +/** + * Computes display names based on the provided values. + */ +export function useZooNames( + { + temporaryName, + solarSystemName, + regionName, + labelCustom, + ownerTicker, + isWormhole, + }: { + temporaryName?: string | null; + solarSystemName?: string | null; + regionName?: string | null; + labelCustom?: string | null; + ownerTicker?: string | null; + isWormhole?: boolean; + ownerId?: string | null; + ownerType?: string | null; + }, + { data: { custom_flags } }: NodeProps, +) { + return useMemo(() => { + const safeSolarSystemName = safeString(solarSystemName); + const safeTemporaryName = safeString(temporaryName, safeSolarSystemName); + const safeRegionName = safeString(regionName); + const safeLabelCustom = safeString(labelCustom); + const safeOwnerTicker = safeString(ownerTicker); + const safeFlags = safeString(custom_flags); + + const computedSystemName = safeTemporaryName; + const computedCustomLabel = isWormhole + ? safeSolarSystemName || safeLabelCustom + : safeTemporaryName !== safeSolarSystemName + ? safeRegionName + : ''; + const computedCustomName = isWormhole + ? `${safeOwnerTicker} ${safeFlags}` + : safeTemporaryName !== safeSolarSystemName + ? `${safeSolarSystemName} ${safeLabelCustom}` + : `${safeRegionName} ${safeLabelCustom}`; + + return { + systemName: computedSystemName, + customLabel: computedCustomLabel, + customName: computedCustomName, + }; + }, [solarSystemName, temporaryName, regionName, labelCustom, ownerTicker, custom_flags, isWormhole]); +} + +/** + * Computes the number of unsplashed signatures adjusted by the number of connections. + */ +export function useZooLabels(connectionCount: number, systemSigs?: SystemSignature[] | null) { + const { unsplashedLeft, unsplashedRight } = useUnsplashedSignatures(systemSigs ?? [], true); + const unsplashedCount = useMemo( + () => unsplashedLeft.length + unsplashedRight.length - connectionCount, + [unsplashedLeft, unsplashedRight, connectionCount], + ); + return { unsplashedCount }; +} + +/** + * Fetches and maintains the ticker and URL for a node owner. + */ +export function useNodeOwnerTicker(ownerId?: string | null, ownerType?: string | null, ownerTicker?: string | null) { + const [ticker, setTicker] = useState(ownerTicker || null); + const [ownerURL, setOwnerURL] = useState(''); + const { outCommand } = useMapState(); + + useEffect(() => { + let isMounted = true; + + // Reset states if no owner info + if (!ownerId && !ownerType && !ownerTicker) { + setTicker(null); + setOwnerURL(''); + return; + } + + // If we have a ticker already, just use that + if (ownerTicker) { + setTicker(ownerTicker); + // Clear the URL when the new owner is ticker-only: leaving it set kept a + // link pointing at the *previous* owner's zKillboard page. + if (ownerId && ownerType) { + const url = `${zkillboardBaseURL}/${ownerType === 'corp' ? 'corporation' : 'alliance'}/${ownerId}`; + setOwnerURL(url); + } else { + setOwnerURL(''); + } + return; + } + + // Only fetch if we have owner ID and type but no ticker + if (ownerId && ownerType) { + // A rejected lookup has to clear the display too, or the node keeps + // rendering the previous owner's ticker and link. + const clearOwner = () => { + if (isMounted) { + setTicker(null); + setOwnerURL(''); + } + }; + + // Clear BEFORE the lookup, not only on rejection. The owner changed the + // moment this effect ran; leaving the previous ticker and zKillboard URL + // on screen until the response lands means the user can click through to + // the wrong corporation. A response with no ticker also has to leave the + // display empty rather than reverting to the stale value. + clearOwner(); + + if (ownerType === 'corp') { + outCommand({ + type: OutCommand.getCorporationTicker, + data: { corp_id: ownerId }, + }) + .then(({ ticker: fetchedTicker }) => { + if (isMounted && fetchedTicker) { + setTicker(fetchedTicker); + setOwnerURL(`${zkillboardBaseURL}/corporation/${ownerId}`); + } + }) + .catch(clearOwner); + } else if (ownerType === 'alliance') { + outCommand({ + type: OutCommand.getAllianceTicker, + data: { alliance_id: ownerId }, + }) + .then(({ ticker: fetchedTicker }) => { + if (isMounted && fetchedTicker) { + setTicker(fetchedTicker); + setOwnerURL(`${zkillboardBaseURL}/alliance/${ownerId}`); + } + }) + .catch(clearOwner); + } + } + + return () => { + isMounted = false; + }; + }, [outCommand, ownerId, ownerType, ownerTicker]); + + return { ownerTicker: ticker, ownerURL }; +} + +/** + * Fetches and maintains signatures for a given system. + * + * On receiving a signaturesUpdated notification, we refetch signatures. + */ +export function useNodeSignatures(systemId: string): SystemSignature[] { + const { outCommand } = useMapRootState(); + const [signatures, setSignatures] = useState([]); + + const fetchSignatures = useCallback(async () => { + try { + const response = await outCommand({ + type: OutCommand.getSignatures, + data: { system_id: systemId }, + }); + setSignatures(response.signatures ?? []); + } catch (error) { + console.error('Failed to fetch signatures', error); + } + }, [outCommand, systemId]); + + useEffect(() => { + fetchSignatures(); + }, [fetchSignatures]); + + // When a signaturesUpdated event is fired for this system, refetch signatures. + useSignatureUpdateListener(systemId, () => { + fetchSignatures(); + }); + + return signatures; +} + +/** + * Computes how long ago this system was last scanned. + * + * See `computeSignatureAge` for which signatures count and why. + */ +export function useSignatureAge(systemSigs?: SystemSignature[] | null) { + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + const interval = setInterval(() => { + setNow(Date.now()); + }, 3600000); // update every hour + return () => clearInterval(interval); + }, []); + + return useMemo(() => ({ ...computeSignatureAge(systemSigs, now), now }), [systemSigs, now]); +} diff --git a/assets/js/hooks/Mapper/components/map/labelIconMap.tsx b/assets/js/hooks/Mapper/components/map/labelIconMap.tsx new file mode 100644 index 000000000..baeac0344 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/labelIconMap.tsx @@ -0,0 +1,102 @@ +import { MdOutlineBlock, MdLocalFireDepartment } from 'react-icons/md'; +import { FaIndustry, FaHourglassEnd, FaExclamationTriangle, FaSkull } from 'react-icons/fa'; + +/** + * Zoo-Specific Label System + * + * The zoo fork repurposes upstream's generic labels (A/B/C/1/2/3) with + * EVE Online wormhole-specific meanings: + * + * | Stored key | Enum member | Upstream | Zoo Meaning | Use Case | + * |------------|-------------|----------|---------------|------------------------------------| + * | de | la | Label A | Dead End | System with no exit wormholes | + * | gas | lb | Label B | Gas Site | System has harvestable gas sites | + * | eol | lc | Label C | End of Life | Wormhole about to collapse (<4h) | + * | crit | l1 | Label 1 | Critical Mass | Wormhole at mass verge | + * | structure | l2 | Label 2 | Structure | System has attackable structure | + * | steve | l3 | Label 3 | Steve/Danger | High danger (historic: player named Steve) | + * + * Note: it is the enum *value* that is persisted, so `system.labels` contains `de`, `gas`, + * `eol`, `crit`, `structure`, `steve` -- never `la`, `lb`, `lc`. The `la`..`l3` member names + * exist only to keep the mapping back to upstream legible and never leave the frontend. + * + * @see constants.ts for MARKER_BOOKMARK_BG_STYLES using these labels + * @see zoo-theme.scss for corresponding CSS classes + */ + +// Extend the LABELS enum with new wormhole keys +export enum LABELS { + clear = 'clear', + la = 'de', + lb = 'gas', + lc = 'eol', + l1 = 'crit', + l2 = 'structure', + l3 = 'steve', +} + +export type LabelIcon = { + icon: React.ReactNode; + colorClass: string; + backgroundColor: string; +}; + +export type LabelInfo = { + id: string; + name: string; + shortName: string; + icon: string; +}; + +// Additional label info for tooltips or lists, etc. +export const LABELS_INFO: Record = { + [LABELS.clear]: { id: 'clear', name: 'Clear', shortName: '', icon: '' }, + [LABELS.la]: { id: 'de', name: 'Dead End', shortName: 'DE', icon: '' }, + [LABELS.lb]: { id: 'gas', name: 'Gas', shortName: 'GAS', icon: '' }, + [LABELS.lc]: { id: 'eol', name: 'Eol', shortName: 'EOL', icon: '' }, + [LABELS.l1]: { id: 'crit', name: 'Crit', shortName: 'CRIT', icon: '' }, + [LABELS.l2]: { id: 'structure', name: 'Structure', shortName: 'LP', icon: '' }, + [LABELS.l3]: { id: 'steve', name: 'Steve', shortName: 'DB', icon: '' }, +}; + +export const LABELS_ORDER = [LABELS.clear, LABELS.la, LABELS.lb, LABELS.lc, LABELS.l1, LABELS.l2, LABELS.l3]; + +// Mapping each label to its icon, text class, and background color. +export const LABEL_ICON_MAP: Record = { + // Dead End: ๐Ÿšซ "No Entry" + [LABELS.la]: { + icon: , + colorClass: 'text-white', + backgroundColor: '#8B0000', // Dark Red + }, + // Gas Cloud: ๐Ÿญ "Harvestable Resource" + [LABELS.lb]: { + icon: , // Dark Cyan + colorClass: 'text-cyan-900', + backgroundColor: '#00BFA5', // Greenish-Cyan + }, + // End of Life (EOL): โณ "Fading Time" + [LABELS.lc]: { + icon: , // White hourglass + colorClass: 'text-white', + backgroundColor: '#FF4500', // Orange-Red + }, + // Critically Closing (CRIT): โš ๏ธ "Danger, Closing Soon" + [LABELS.l1]: { + icon: , + colorClass: 'text-white', + backgroundColor: '#B22222', // Firebrick Red + }, + // Low Power Structure (๐Ÿ”ฅ More Contrast) + [LABELS.l2]: { + icon: , // Bright Gold Fire + colorClass: 'text-yellow-500', + backgroundColor: '#5D1E1E', // Deep Maroon + }, + // Death immenient (Steve): ๐Ÿ’€ "Danger" + [LABELS.l3]: { + icon: , // Black Skull + colorClass: 'text-black', + backgroundColor: '#FFFFFF', // Pure White Background - should not appear red + }, +}; diff --git a/assets/js/hooks/Mapper/components/map/map.types.ts b/assets/js/hooks/Mapper/components/map/map.types.ts index c16652e7c..6055cd3fe 100644 --- a/assets/js/hooks/Mapper/components/map/map.types.ts +++ b/assets/js/hooks/Mapper/components/map/map.types.ts @@ -1,8 +1,14 @@ import { SolarSystemRawType } from '@/hooks/Mapper/types/system'; -import { SolarSystemConnection } from '@/hooks/Mapper/types'; +import { SolarSystemConnection } from '@/hooks/Mapper/types/connection'; import { XYPosition } from 'reactflow'; -export type MapSolarSystemType = Omit; +export type MapSolarSystemType = Omit & { + solar_system_id: number; + position_x: number; + position_y: number; + visible: boolean; + owner_ticker: string | null; +}; export type OnMapSelectionChange = (event: { systems: string[]; diff --git a/assets/js/hooks/Mapper/components/map/styles/eve-common-variables.scss b/assets/js/hooks/Mapper/components/map/styles/eve-common-variables.scss index 450259042..daefb2b3a 100644 --- a/assets/js/hooks/Mapper/components/map/styles/eve-common-variables.scss +++ b/assets/js/hooks/Mapper/components/map/styles/eve-common-variables.scss @@ -120,5 +120,6 @@ $homeDark30: color.adjust($homeBase, $lightness: -30%); --conn-frigate: #325d88; --conn-bridge: rgba(135, 185, 93, 0.85); --conn-save: rgba(155, 102, 45, 0.85); + --conn-loop: #4a90e2; --selected-item-bg: rgba(98, 98, 98, 0.33); } diff --git a/assets/js/hooks/Mapper/components/map/styles/index.scss b/assets/js/hooks/Mapper/components/map/styles/index.scss index e88568685..92846756f 100644 --- a/assets/js/hooks/Mapper/components/map/styles/index.scss +++ b/assets/js/hooks/Mapper/components/map/styles/index.scss @@ -1,5 +1,6 @@ @use './default-theme.scss'; @use './pathfinder-theme.scss'; +@use './zoo-theme.scss'; @use './accessible-dark-theme.scss'; @use './accessible-large-theme.scss'; @use './accessible-large-colorblind-theme.scss'; diff --git a/assets/js/hooks/Mapper/components/map/styles/zoo-theme.scss b/assets/js/hooks/Mapper/components/map/styles/zoo-theme.scss new file mode 100644 index 000000000..f9eb1b911 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/styles/zoo-theme.scss @@ -0,0 +1,101 @@ +@use './pathfinder-theme.scss'; + +.zoo-theme { + @extend .pathfinder-theme; + --rf-bg-gap: 34; + --rf-snap-sizeX: 238; + --rf-snap-sizeY: 51; + --rf-has-user-characters: #5cb85c; + + .eve-zoo-effect-color-has-eol { + fill: #FF69B4; // Pinker EOL + background-color: #FF69B4; + } + + .eve-zoo-effect-color-unsplashed { + fill: #2C3E50; + background-color: #2C3E50; + } + + .eve-zoo-effect-color-has-gas { + fill: #FFFDD0; // Creamier Cloud + background-color: #FFFDD0; + } + + .eve-zoo-effect-color-is-critical { + fill: #8B0000; // Crit Dark Red + background-color: #8B0000; + } + + .eve-zoo-effect-color-is-dead-end { + fill: #34495E; + background-color: #34495E; + } + + .eve-zoo-effect-color-flygd { + fill: #F0F0F0; + background-color: #F0F0F0; + } + + .eve-zoo-effect-color-wormhole { + fill: #FFFFE0; + background-color: #FFFFE0; + } + + .eve-zoo-effect-color-wormhole-magic { + fill: #9B59B6; + background-color: #9B59B6; + } + + .eve-zoo-effect-color-wormhole-infinity { + fill: #3498DB; + background-color: #3498DB; + } + + .eve-zoo-effect-color-wormhole-planet { + fill: #27AE60; + background-color: #27AE60; + } + + .eve-zoo-effect-color-wormhole-loop { + fill: #E74C3C; + background-color: #E74C3C; + } + + /* Zoo Text Color Classes */ + .text-eve-zoo-effect-color-has-eol { + color: #FF69B4; + } + + .text-eve-zoo-effect-color-unsplashed { + color: #CCCCCC; + } + + .text-eve-zoo-effect-color-has-gas { + color: #FFFDD0; + } + + .text-eve-zoo-effect-color-is-critical { + color: #8B0000; + } + + .text-eve-zoo-effect-color-is-dead-end { + color: #34495E; + } + + .text-eve-zoo-effect-color-wormhole-magic { + color: #9B59B6; + } + + .text-eve-zoo-effect-color-wormhole-infinity { + color: #3498DB; + } + + .text-eve-zoo-effect-color-wormhole-planet { + color: #27AE60; + } + + .text-eve-zoo-effect-color-wormhole-loop { + color: #E74C3C; + } +} diff --git a/assets/js/hooks/Mapper/components/map/zooConstants.ts b/assets/js/hooks/Mapper/components/map/zooConstants.ts new file mode 100644 index 000000000..f4c29c0e4 --- /dev/null +++ b/assets/js/hooks/Mapper/components/map/zooConstants.ts @@ -0,0 +1,79 @@ +/** + * Zoo-Specific Constants + * + * This file contains zoo fork-specific constants that extend the upstream + * Wanderer constants. Keeping them separate reduces merge conflict risk + * when rebasing on upstream changes. + * + * @see constants.ts for how these are merged with upstream constants + * @see zoo-theme.scss for corresponding CSS classes + */ + +import { ConnectionType } from '../../types/connection'; + +/** + * Zoo-specific connection types extending the upstream ConnectionType enum. + * + * Note: The `loop` connection type is already included in the upstream + * ConnectionType enum (connection.ts), so we just re-export for clarity. + */ +export const ZOO_CONNECTION_TYPES = { + ...ConnectionType, + // loop is already in ConnectionType, but documented here for zoo-specific usage: + // loop: 3 - Used for marking connections that loop back (self-referential chains) +}; + +/** + * Zoo-specific bookmark/label background styles. + * + * These styles map to CSS classes defined in zoo-theme.scss. + * They represent EVE Online wormhole-specific visual states: + * + * | Key | CSS Class | Purpose | + * |----------------|------------------------------------|-----------------------------| + * | gas | eve-zoo-effect-color-has-gas | System has gas sites | + * | eol | eve-zoo-effect-color-has-eol | Wormhole is End of Life | + * | deadEnd | eve-zoo-effect-color-is-dead-end | Dead end system | + * | crit | eve-zoo-effect-color-is-critical | Wormhole at critical mass | + * | unSplashed | eve-zoo-effect-color-unsplashed | System not yet scouted | + * | de | eve-zoo-effect-color-is-dead-end | Alias for deadEnd | + * | wormhole | eve-zoo-effect-color-wormhole | Generic wormhole marker | + * | flygd | eve-zoo-effect-color-flygd | Flygd marker style | + * | wormholeMagic | eve-zoo-effect-color-wormhole-magic | Special wormhole variant | + * | wormholeInfinity | eve-zoo-effect-color-wormhole-infinity | Infinity chain marker | + * | wormholePlanet | eve-zoo-effect-color-wormhole-planet | Planet-related marker | + * | wormholeLoop | eve-zoo-effect-color-wormhole-loop | Loop connection marker | + */ +export const ZOO_BOOKMARK_STYLES = { + gas: 'eve-zoo-effect-color-has-gas', + eol: 'eve-zoo-effect-color-has-eol', + deadEnd: 'eve-zoo-effect-color-is-dead-end', + crit: 'eve-zoo-effect-color-is-critical', + unSplashed: 'eve-zoo-effect-color-unsplashed', + de: 'eve-zoo-effect-color-is-dead-end', + wormhole: 'eve-zoo-effect-color-wormhole', + flygd: 'eve-zoo-effect-color-flygd', + wormholeMagic: 'eve-zoo-effect-color-wormhole-magic', + wormholeInfinity: 'eve-zoo-effect-color-wormhole-infinity', + wormholePlanet: 'eve-zoo-effect-color-wormhole-planet', + wormholeLoop: 'eve-zoo-effect-color-wormhole-loop', +} as const; + +/** + * Zoo-specific text color styles for labels. + * These correspond to the background styles but for text coloring. + */ +export const ZOO_TEXT_STYLES = { + gas: 'text-eve-zoo-effect-color-has-gas', + eol: 'text-eve-zoo-effect-color-has-eol', + deadEnd: 'text-eve-zoo-effect-color-is-dead-end', + crit: 'text-eve-zoo-effect-color-is-critical', + unSplashed: 'text-eve-zoo-effect-color-unsplashed', + wormholeMagic: 'text-eve-zoo-effect-color-wormhole-magic', + wormholeInfinity: 'text-eve-zoo-effect-color-wormhole-infinity', + wormholePlanet: 'text-eve-zoo-effect-color-wormhole-planet', + wormholeLoop: 'text-eve-zoo-effect-color-wormhole-loop', +} as const; + +export type ZooBookmarkStyleKey = keyof typeof ZOO_BOOKMARK_STYLES; +export type ZooTextStyleKey = keyof typeof ZOO_TEXT_STYLES; diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/Comments/Comments.tsx b/assets/js/hooks/Mapper/components/mapInterface/components/Comments/Comments.tsx index 1f3d3327a..88eac1b17 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/components/Comments/Comments.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/components/Comments/Comments.tsx @@ -39,15 +39,15 @@ export const Comments = ({}: CommentsProps) => { if (commentsList.length === 0) { return (
      - Not comments found here + No comments found here
      ); } return (
      - {commentsList.map(({ id, text, updated_at, characterEveId }) => ( - + {commentsList.map(({ id, text, updated_at, characterEveId, inherited_from_map_id }) => ( + ))}
      ); diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/Comments/components/MarkdownComment/MarkdownComment.tsx b/assets/js/hooks/Mapper/components/mapInterface/components/Comments/components/MarkdownComment/MarkdownComment.tsx index 9b5314a79..130a588f6 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/components/Comments/components/MarkdownComment/MarkdownComment.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/components/Comments/components/MarkdownComment/MarkdownComment.tsx @@ -22,9 +22,10 @@ export interface MarkdownCommentProps { time: string; characterEveId: string; id: string; + inherited?: boolean; } -export const MarkdownComment = ({ text, time, characterEveId, id }: MarkdownCommentProps) => { +export const MarkdownComment = ({ text, time, characterEveId, id, inherited }: MarkdownCommentProps) => { const char = useGetCacheCharacter(characterEveId); const [hovered, setHovered] = useState(false); @@ -57,16 +58,18 @@ export const MarkdownComment = ({ text, time, characterEveId, id }: MarkdownComm onMouseLeave={handleMouseLeave} title={
      -
      +
      by {char?.data?.name ?? ''} + {inherited && ( + inherited + )}
      - {!hovered && } - {hovered && ( + {hovered && !inherited ? ( // @ts-ignore
      + ) : ( + )}
      @@ -84,14 +89,16 @@ export const MarkdownComment = ({ text, time, characterEveId, id }: MarkdownComm {text} - + {!inherited && ( + + )} ); }; diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/MarkdownEditor/MarkdownEditor.tsx b/assets/js/hooks/Mapper/components/mapInterface/components/MarkdownEditor/MarkdownEditor.tsx index 36ae8464a..1d4d8a3ae 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/components/MarkdownEditor/MarkdownEditor.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/components/MarkdownEditor/MarkdownEditor.tsx @@ -46,6 +46,7 @@ export interface MarkdownEditorProps { onChange: (value: string) => void; height?: string; className?: string; + readOnly?: boolean; } export const MarkdownEditor = ({ @@ -54,6 +55,7 @@ export const MarkdownEditor = ({ overlayContent, height = '70px', className, + readOnly = false, }: MarkdownEditorProps) => { const [hasShift, setHasShift] = useState(false); @@ -71,7 +73,7 @@ export const MarkdownEditor = ({ }, []); return ( -
      +
      = ({ + systemId, + visible, + setVisible, +}) => { + const inputRef = useRef(null); + + const { + system, + label, + setLabel, + temporaryName, + setTemporaryName, + description, + setDescription, + ownerName, + setOwnerName, + setOwnerId, + setOwnerType, + selectedFlags, + setSelectedFlags, + ownerSuggestions, + setOwnerSuggestions, + searchOwners, + handleOwnerSelect, + handleOwnerChange, + handleSave, + } = useCustomSystemSettings(systemId, visible); + + const isWormhole = system ? isWormholeSpace(system.system_static_info.system_class) : false; + + const onShow = useCallback(() => { + inputRef.current?.focus(); + }, []); + + // Wrap the searchOwners function with a debounce of 300ms. Debouncing only + // limits how often the request goes out โ€” two in-flight lookups can still + // resolve out of order, so a slow early query would overwrite the newer + // suggestions. The generation counter drops any response that is no longer + // the latest. + // + // The generation is bumped by the CALLER, before the debounce is scheduled, + // and passed in. Bumping it inside the debounced body would leave an + // already-in-flight query holding the current generation for the whole + // debounce interval, so its stale results would still be applied โ€” and the + // user could select an owner that does not match what they typed. + const searchGeneration = useRef(0); + const debouncedSearch = useMemo( + () => + debounce(async (query: string, generation: number) => { + const results = await searchOwners(query); + + if (generation === searchGeneration.current) { + setOwnerSuggestions(results); + } + }, 300), + [searchOwners, setOwnerSuggestions], + ); + + // Clean up the debounce on unmount. + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + const completeMethod = useCallback( + (e: { originalEvent: React.SyntheticEvent; query: string }) => { + // Either branch invalidates whatever is already in flight. + const generation = ++searchGeneration.current; + + if (e.query && e.query.length >= 3) { + debouncedSearch(e.query, generation); + } else { + // Same race in reverse: clearing below does nothing if a lookup issued + // for a longer query is still in flight. Cancel the pending call; the + // bump above already invalidated any that went out. + debouncedSearch.cancel(); + setOwnerSuggestions([]); + } + }, + [debouncedSearch, setOwnerSuggestions], + ); + + // This handler enforces uppercase letters and numbers only for the bookmark name. + const handleTemporaryNameChange = useCallback( + (e: React.ChangeEvent) => { + let value = e.target.value.toUpperCase(); + // Allow letters, numbers, spaces and forward slashes + value = value.replace(/[^A-Z0-9 /]/g, ''); + setTemporaryName(value); + }, + [setTemporaryName], + ); + + const onSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + await handleSave(); + setVisible(false); + }, + [handleSave, ownerName, system, setVisible], + ); + + return ( + setVisible(false)} + > +
      +
      +
      + {/* Bookmark Name Field */} +
      + + + {temporaryName && ( + setTemporaryName('')} + /> + )} + + +
      + {/* Conditional rendering for Tag vs. Ticker/Flags */} + {!isWormhole ? ( +
      + + + {label && ( + setLabel('')} + /> + )} + setLabel(e.target.value.toUpperCase())} + /> + +
      + ) : ( + <> + {/* Ticker Field */} +
      + + + {ownerName && ( + { + setOwnerName(''); + setOwnerId(''); + setOwnerType(''); + }} + /> + )} + handleOwnerChange(e.value)} + onSelect={e => handleOwnerSelect(e.value)} + field="formatted" + forceSelection={false} + /> + +
      + {/* Custom Flags Field */} +
      + +
      + {CHECKBOX_ITEMS.map(item => { + const checked = selectedFlags.includes(item.code); + return ( +
      + { + const isChecked = e.checked ?? false; + if (isChecked) { + setSelectedFlags(prev => [...prev, item.code]); + } else { + setSelectedFlags(prev => prev.filter(flag => flag !== item.code)); + } + }} + /> + +
      + ); + })} +
      +
      + + )} + {/* Notes Field */} +
      + + setDescription(e.target.value)} + /> +
      +
      +
      + +
      +
      +
      +
      + ); +}; diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/SystemSettingsDialog.tsx b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/SystemSettingsDialog.tsx index 1d334c31c..1a30a9384 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/SystemSettingsDialog.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/SystemSettingsDialog.tsx @@ -19,11 +19,12 @@ interface SystemSettingsDialog { export const SystemSettingsDialog = ({ systemId, visible, setVisible }: SystemSettingsDialog) => { const { - data: { systems }, + data: { systems, options: mapOptions }, outCommand, } = useMapRootState(); const isTempSystemNameEnabled = useMapGetOption('show_temp_system_name') === 'true'; + const hasIntelSource = !!mapOptions?.intel_source_map_id; const system = getSystemById(systems, systemId); const systemStaticInfo = getSystemStaticInfo(systemId); @@ -34,49 +35,89 @@ export const SystemSettingsDialog = ({ systemId, visible, setVisible }: SystemSe const [description, setDescription] = useState(''); const inputRef = useRef(); - const ref = useRef({ name, description, temporaryName, label, outCommand, systemId, system, systemStaticInfo }); - ref.current = { name, description, label, temporaryName, outCommand, systemId, system, systemStaticInfo }; - - const handleSave = useCallback(() => { - const { name, description, label, temporaryName, outCommand, systemId, system, systemStaticInfo } = ref.current; - - const outLabel = new LabelsManager(system?.labels ?? ''); - outLabel.updateCustomLabel(label); - - outCommand({ - type: OutCommand.updateSystemLabels, - data: { - system_id: systemId, - value: outLabel.toString(), - }, - }); - - outCommand({ - type: OutCommand.updateSystemTemporaryName, - data: { - system_id: systemId, - value: temporaryName, - }, - }); - - outCommand({ - type: OutCommand.updateSystemName, - data: { - system_id: systemId, - value: name.trim() || systemStaticInfo?.solar_system_name, - }, - }); - - outCommand({ - type: OutCommand.updateSystemDescription, - data: { - system_id: systemId, - value: description, - }, - }); - - setVisible(false); - }, [setVisible]); + const ref = useRef({ + name, + description, + temporaryName, + label, + outCommand, + systemId, + system, + systemStaticInfo, + hasIntelSource, + }); + ref.current = { + name, + description, + label, + temporaryName, + outCommand, + systemId, + system, + systemStaticInfo, + hasIntelSource, + }; + + const handleSave = useCallback( + (e?: React.FormEvent) => { + if (e) e.preventDefault(); + const { + name, + description, + label, + temporaryName, + outCommand, + systemId, + system, + systemStaticInfo, + hasIntelSource, + } = ref.current; + + // The Save button is disabled under an intel source, but the form still + // submits on Enter โ€” drop the write here rather than let it reach the server. + if (hasIntelSource) { + return; + } + + const outLabel = new LabelsManager(system?.labels ?? ''); + outLabel.updateCustomLabel(label); + + outCommand({ + type: OutCommand.updateSystemLabels, + data: { + system_id: systemId, + value: outLabel.toString(), + }, + }); + + outCommand({ + type: OutCommand.updateSystemTemporaryName, + data: { + system_id: systemId, + value: temporaryName, + }, + }); + + outCommand({ + type: OutCommand.updateSystemName, + data: { + system_id: systemId, + value: name.trim() || systemStaticInfo?.solar_system_name, + }, + }); + + outCommand({ + type: OutCommand.updateSystemDescription, + data: { + system_id: systemId, + value: description, + }, + }); + + setVisible(false); + }, + [setVisible], + ); const handleResetSystemName = useCallback(() => { const { systemStaticInfo } = ref.current; @@ -124,14 +165,20 @@ export const SystemSettingsDialog = ({ systemId, visible, setVisible }: SystemSe setVisible(false); }} > -
      +
      + {hasIntelSource && ( +
      + + These fields are managed by the intel source map and cannot be edited here. +
      + )}
      - {name !== systemStaticInfo?.solar_system_name && ( + {!hasIntelSource && name !== systemStaticInfo?.solar_system_name && ( setName(e.target.value)} @@ -159,7 +207,7 @@ export const SystemSettingsDialog = ({ systemId, visible, setVisible }: SystemSe - {label !== '' && ( + {!hasIntelSource && label !== '' && ( setLabel(e.target.value)} onInput={handleInput} /> @@ -188,7 +237,7 @@ export const SystemSettingsDialog = ({ systemId, visible, setVisible }: SystemSe - {temporaryName !== '' && ( + {!hasIntelSource && temporaryName !== '' && ( setTemporaryName(e.target.value)} /> @@ -215,13 +265,18 @@ export const SystemSettingsDialog = ({ systemId, visible, setVisible }: SystemSe
      - setDescription(e)} height="180px" /> + setDescription(e)} + height="180px" + readOnly={hasIntelSource} + />
      - +
      diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/helpers.ts b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/helpers.ts new file mode 100644 index 000000000..9916c3e44 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/helpers.ts @@ -0,0 +1,24 @@ +// customSystemSettingsHelpers.ts + +export const VALID_FLAG_CODES = new Set([ + 'B', // Blobber + 'MB', // Marauder Blobber + 'C', // Check Notes + 'F', // Farm + 'PW', // Prewarp Sites + 'PT', // POS Trash + 'DNP', // Do Not Pod +]); + +export function parseTagString(str: string): string[] { + if (!str) return []; + return str + .trim() + .split(/\s+/) + .map(item => item.replace(/^\*/, '')) + .filter(code => code && VALID_FLAG_CODES.has(code)); +} + +export function toTagString(arr: string[]): string { + return arr.map(code => `*${code}`).join(' '); +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/index.ts b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/index.ts new file mode 100644 index 000000000..8b7c7fa3e --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/index.ts @@ -0,0 +1,2 @@ +export * from './helpers'; +export * from './types'; diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/types.ts b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/types.ts new file mode 100644 index 000000000..a75a30aa8 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/helpers/types.ts @@ -0,0 +1,19 @@ +// CustomSystemSettingsDialog.types.ts + +export interface OwnerSuggestion { + label: string; + value: string; + corporation?: boolean; + alliance?: boolean; + formatted: string; + name: string; + ticker: string; + id: string; + type: 'corp' | 'alliance'; +} + +export interface CustomSystemSettingsDialogProps { + systemId: string; + visible: boolean; + setVisible: (visible: boolean) => void; +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/hooks/useCustomSystemSettings.ts b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/hooks/useCustomSystemSettings.ts new file mode 100644 index 000000000..f63922311 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/hooks/useCustomSystemSettings.ts @@ -0,0 +1,407 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { OutCommand } from '@/hooks/Mapper/types'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { getSystemById } from '@/hooks/Mapper/helpers'; +import { LabelsManager } from '@/hooks/Mapper/utils/labelsManager'; +import { parseTagString, toTagString } from '../helpers'; + +export interface OwnerSuggestion { + label: string; + value: string; + corporation?: boolean; + alliance?: boolean; + formatted: string; + name: string; + ticker: string; + id: string; + type: 'corp' | 'alliance'; +} + +/** + * Custom hook to manage the state and logic for the CustomSystemSettingsDialog. + * + * @param systemId - The ID of the system to edit. + * @param visible - Whether the dialog is visible. + */ +export function useCustomSystemSettings(systemId: string, visible: boolean) { + const { + data: { systems }, + outCommand, + } = useMapRootState(); + const system = getSystemById(systems, systemId); + + // Local state declarations. + const [name, setName] = useState(''); + const [label, setLabel] = useState(''); + const [temporaryName, setTemporaryName] = useState(''); + const [description, setDescription] = useState(''); + const [ownerName, setOwnerName] = useState(''); + const [ownerId, setOwnerId] = useState(''); + const [ownerType, setOwnerType] = useState<'corp' | 'alliance' | ''>(''); + const [selectedFlags, setSelectedFlags] = useState([]); + const [prevOwnerQuery, setPrevOwnerQuery] = useState(''); + const [prevOwnerResults, setPrevOwnerResults] = useState([]); + const [ownerSuggestions, setOwnerSuggestions] = useState([]); + + // Cache for ticker lookups. + const tickerCacheRef = useRef>({}); + + // Use a ref to ensure initialization only happens once per dialog open. + const initializedRef = useRef(false); + + // Use refs to store owner info so we don't lose it when state updates. + const ownerInfoRef = useRef<{ + ownerId: string; + ownerType: 'corp' | 'alliance' | ''; + ownerName: string; + }>({ ownerId: '', ownerType: '', ownerName: '' }); + + // Initialization: only run when dialog is visible and not yet initialized. + useEffect(() => { + if (!visible) { + // When the dialog is closed, reset the initialization flag. + initializedRef.current = false; + return; + } + if (visible && system && !initializedRef.current) { + // Initialize text fields. + setName(system.name || ''); + setDescription(system.description || ''); + setTemporaryName(system.temporary_name || ''); + + // Handle owner ticker logic. + if (system.owner_id && system.owner_type) { + setOwnerId(system.owner_id || ''); + setOwnerType((system.owner_type as '' | 'corp' | 'alliance') || ''); + ownerInfoRef.current.ownerId = system.owner_id || ''; + ownerInfoRef.current.ownerType = (system.owner_type as '' | 'corp' | 'alliance') || ''; + + const cacheKey = `${system.owner_type}_${system.owner_id}`; + if (tickerCacheRef.current[cacheKey]) { + setOwnerName(tickerCacheRef.current[cacheKey]); + ownerInfoRef.current.ownerName = tickerCacheRef.current[cacheKey]; + } else { + // Safely check if owner_ticker exists on the system object and has a value + const tickerFromSystem = 'owner_ticker' in system && system.owner_ticker ? String(system.owner_ticker) : null; + + if (tickerFromSystem) { + setOwnerName(tickerFromSystem); + ownerInfoRef.current.ownerName = tickerFromSystem; + tickerCacheRef.current[cacheKey] = tickerFromSystem; + } else { + // Fetch ticker if not found directly + if (system.owner_type === 'corp') { + outCommand({ + type: OutCommand.getCorporationTicker, + data: { corp_id: system.owner_id }, + }).then(({ ticker }) => { + if (ticker) { + setOwnerName(ticker); + ownerInfoRef.current.ownerName = ticker; + tickerCacheRef.current[`corp_${system.owner_id}`] = ticker; + } + }); + } else if (system.owner_type === 'alliance') { + outCommand({ + type: OutCommand.getAllianceTicker, + data: { alliance_id: system.owner_id }, + }).then(({ ticker }) => { + if (ticker) { + setOwnerName(ticker); + ownerInfoRef.current.ownerName = ticker; + tickerCacheRef.current[`alliance_${system.owner_id}`] = ticker; + } + }); + } + } + } + } else { + // Handle case where only owner_ticker might exist (without id/type) + const tickerFromSystem = 'owner_ticker' in system && system.owner_ticker ? String(system.owner_ticker) : null; + if (tickerFromSystem) { + setOwnerName(tickerFromSystem); + ownerInfoRef.current.ownerName = tickerFromSystem; + setOwnerId(''); + setOwnerType(''); + ownerInfoRef.current.ownerId = ''; + ownerInfoRef.current.ownerType = ''; + } else { + // Reset owner info if no id, type, or ticker is found + setOwnerId(''); + setOwnerType(''); + setOwnerName(''); + ownerInfoRef.current.ownerId = ''; + ownerInfoRef.current.ownerType = ''; + ownerInfoRef.current.ownerName = ''; + } + } + + // Parse and set custom flags. + if (system.custom_flags) { + setSelectedFlags(parseTagString(system.custom_flags)); + } else { + setSelectedFlags([]); + } + + // Parse and set the custom label. + try { + const labelsObj = JSON.parse(system.labels || '{}'); + setLabel(labelsObj.customLabel || ''); + } catch (e) { + setLabel(''); + } + + initializedRef.current = true; + } + }, [visible, system, outCommand]); + + // Searches for owner suggestions based on a query string. + const searchOwners = useCallback( + async (newQuery: string): Promise => { + if (newQuery.length < 3) return []; + if (prevOwnerQuery && newQuery.startsWith(prevOwnerQuery) && prevOwnerResults.length > 0) { + const filtered = prevOwnerResults.filter(item => item.formatted.toLowerCase().includes(newQuery.toLowerCase())); + + // Find exact ticker matches + const exactMatches = filtered.filter(item => item.ticker.toLowerCase() === newQuery.toLowerCase()); + + // Return exact matches first, then other filtered results + if (exactMatches.length > 0) { + const otherResults = filtered.filter(item => item.ticker.toLowerCase() !== newQuery.toLowerCase()); + return [...exactMatches, ...otherResults]; + } + + return filtered; + } + // Fetch suggestions for both corporations and alliances. + const corpPromise = outCommand({ + type: OutCommand.getCorporationNames, + data: { search: newQuery }, + }).catch(error => { + console.error('[searchOwners] Corporation search error:', error); + return null; + }); + const alliancePromise = outCommand({ + type: OutCommand.getAllianceNames, + data: { search: newQuery }, + }).catch(error => { + console.error('[searchOwners] Alliance search error:', error); + return null; + }); + const [corpResponse, allianceResponse] = await Promise.all([corpPromise, alliancePromise]); + + const corpResults = corpResponse?.results || []; + const allianceResults = allianceResponse?.results || []; + + const combinedResults: OwnerSuggestion[] = [ + ...corpResults.map((r: Omit) => { + if (r.ticker) { + tickerCacheRef.current[`corp_${r.value}`] = r.ticker; + } + return { ...r, corporation: true, alliance: false } as OwnerSuggestion; + }), + ...allianceResults.map((r: Omit) => { + if (r.ticker) { + tickerCacheRef.current[`alliance_${r.value}`] = r.ticker; + } + return { ...r, corporation: false, alliance: true } as OwnerSuggestion; + }), + ]; + + // Find exact ticker matches + const exactTickerMatches = combinedResults.filter(item => item.ticker.toLowerCase() === newQuery.toLowerCase()); + + // If we have exact ticker matches, prioritize them + if (exactTickerMatches.length > 0) { + // Get other results that aren't exact ticker matches + const otherResults = combinedResults.filter(item => item.ticker.toLowerCase() !== newQuery.toLowerCase()); + + // Store all results for future filtering + setPrevOwnerQuery(newQuery); + setPrevOwnerResults([...exactTickerMatches, ...otherResults]); + + // Return exact matches first, then other results + return [...exactTickerMatches, ...otherResults]; + } + + setPrevOwnerQuery(newQuery); + setPrevOwnerResults(combinedResults); + return combinedResults; + }, + [prevOwnerQuery, prevOwnerResults, outCommand], + ); + + // Handler when an owner suggestion is selected. + const handleOwnerSelect = useCallback( + (selected: OwnerSuggestion) => { + if (selected) { + if (selected.ticker) { + setOwnerName(selected.ticker); + ownerInfoRef.current.ownerName = selected.ticker; + + // Cache the ticker + const cacheKey = `${selected.type}_${selected.id}`; + tickerCacheRef.current[cacheKey] = selected.ticker; + } else { + setOwnerName(selected.name); + ownerInfoRef.current.ownerName = selected.name; + if (selected.type === 'corp') { + outCommand({ + type: OutCommand.getCorporationTicker, + data: { corp_id: selected.id }, + }).then(({ ticker }) => { + if (ticker) { + tickerCacheRef.current[`corp_${selected.id}`] = ticker; + setOwnerName(ticker); + ownerInfoRef.current.ownerName = ticker; + } + }); + } else if (selected.type === 'alliance') { + outCommand({ + type: OutCommand.getAllianceTicker, + data: { alliance_id: selected.id }, + }).then(({ ticker }) => { + if (ticker) { + tickerCacheRef.current[`alliance_${selected.id}`] = ticker; + setOwnerName(ticker); + ownerInfoRef.current.ownerName = ticker; + } + }); + } + } + setOwnerId(selected.id); + setOwnerType(selected.type); + ownerInfoRef.current.ownerId = selected.id; + ownerInfoRef.current.ownerType = selected.type; + } + }, + [outCommand], + ); + + // Handler for changes in the owner field. + const handleOwnerChange = useCallback((value: string | OwnerSuggestion) => { + if (value) { + if (typeof value === 'string') { + // User is typing directly, not selecting. + // Update the name, but clear the ID and Type to avoid sending stale data. + setOwnerName(value); + setOwnerId(''); + setOwnerType(''); + ownerInfoRef.current.ownerName = value; + ownerInfoRef.current.ownerId = ''; + ownerInfoRef.current.ownerType = ''; + } else { + // Value is an OwnerSuggestion object (likely from selection, though handled by handleOwnerSelect primarily) + // Update all fields based on the object. + setOwnerName(value.name); + setOwnerId(value.id); + setOwnerType(value.type); + ownerInfoRef.current.ownerName = value.name; + ownerInfoRef.current.ownerId = value.id; + ownerInfoRef.current.ownerType = value.type; + } + } else { + // Value is null/undefined (field cleared) + // Clear all owner info. + setOwnerName(''); + setOwnerId(''); + setOwnerType(''); + ownerInfoRef.current.ownerName = ''; + ownerInfoRef.current.ownerId = ''; + ownerInfoRef.current.ownerType = ''; + } + }, []); + + // Save handler that calls the update commands and returns a promise. + const handleSave = useCallback(async () => { + if (!system) return; + + // Use ownerInfoRef values instead of state values to ensure we have the most up-to-date info + const currentOwnerId = ownerInfoRef.current.ownerId; + const currentOwnerType = ownerInfoRef.current.ownerType; + const currentOwnerName = ownerInfoRef.current.ownerName; + const currentSystemId = system.id; + + // Update the ticker cache if we have valid owner info + if (currentOwnerId && currentOwnerType && currentOwnerName) { + const cacheKey = `${currentOwnerType}_${currentOwnerId}`; + tickerCacheRef.current[cacheKey] = currentOwnerName; + } + + const lm = new LabelsManager(system.labels ?? ''); + lm.updateCustomLabel(label); + + const updatePromises = [ + outCommand({ + type: OutCommand.updateSystemLabels, + data: { system_id: currentSystemId, value: lm.toString() }, + }), + outCommand({ + type: OutCommand.updateSystemName, + data: { + system_id: currentSystemId, + value: name.trim() || system.system_static_info.solar_system_name, + }, + }), + outCommand({ + type: OutCommand.updateSystemTemporaryName, + data: { system_id: currentSystemId, value: temporaryName }, + }), + outCommand({ + type: OutCommand.updateSystemDescription, + data: { system_id: currentSystemId, value: description }, + }), + ]; + + // Always send owner update with complete information + const ownerData = { + system_id: currentSystemId, + owner_id: currentOwnerId === '' ? null : currentOwnerId, + owner_type: currentOwnerType === '' ? null : currentOwnerType, + owner_ticker: currentOwnerName === '' ? null : currentOwnerName, + }; + + updatePromises.push( + outCommand({ + type: OutCommand.updateSystemOwner, + data: ownerData, + }), + ); + + const flagsStr = toTagString(selectedFlags); + updatePromises.push( + outCommand({ + type: OutCommand.updateSystemCustomFlags, + data: { system_id: currentSystemId, value: flagsStr === '' ? null : flagsStr }, + }), + ); + + await Promise.all(updatePromises); + }, [system, name, label, temporaryName, description, selectedFlags, outCommand]); + + return { + system, + name, + setName, + label, + setLabel, + temporaryName, + setTemporaryName, + description, + setDescription, + ownerName, + setOwnerName, + ownerId, + setOwnerId, + ownerType, + setOwnerType, + selectedFlags, + setSelectedFlags, + ownerSuggestions, + setOwnerSuggestions, + searchOwners, + handleOwnerSelect, + handleOwnerChange, + handleSave, + }; +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/index.ts b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/index.ts index af6b997d6..630519148 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/index.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/index.ts @@ -1 +1,2 @@ export * from './SystemSettingsDialog'; +export * from './CustomSystemSettingsDialog'; diff --git a/assets/js/hooks/Mapper/components/mapInterface/constants.tsx b/assets/js/hooks/Mapper/components/mapInterface/constants.tsx index 2396bb4ea..09f85156e 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/constants.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/constants.tsx @@ -26,12 +26,7 @@ export enum WidgetsIds { userRoutes = 'userRoutes', } -export const STORED_VISIBLE_WIDGETS_DEFAULT = [ - WidgetsIds.info, - WidgetsIds.local, - WidgetsIds.routes, - WidgetsIds.signatures, -]; +export const STORED_VISIBLE_WIDGETS_DEFAULT = [WidgetsIds.routes, WidgetsIds.signatures]; export const DEFAULT_WIDGETS: WindowProps[] = [ { @@ -142,3 +137,12 @@ export const WIDGETS_CHECKBOXES_PROPS: WidgetsCheckboxesType = [ label: 'Comments', }, ]; + +export function getWidgetsCheckboxesProps(detailedKillsDisabled: boolean): WidgetsCheckboxesType { + return filterOutKills(WIDGETS_CHECKBOXES_PROPS, detailedKillsDisabled); +} + +function filterOutKills(items: T[], shouldFilter: boolean) { + if (!shouldFilter) return items; + return items.filter(w => w.id !== WidgetsIds.kills); +} diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemInfo/SystemInfo.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemInfo/SystemInfo.tsx index 498bd3f3d..80bac5b0b 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemInfo/SystemInfo.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemInfo/SystemInfo.tsx @@ -1,12 +1,12 @@ import { Widget } from '@/hooks/Mapper/components/mapInterface/components'; -import { SystemSettingsDialog } from '@/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog/SystemSettingsDialog.tsx'; import { LayoutEventBlocker, SystemView, TooltipPosition, WdImgButton } from '@/hooks/Mapper/components/ui-kit'; -import { ANOIK_ICON, DOTLAN_ICON, ZKB_ICON } from '@/hooks/Mapper/icons'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; -import { getSystemStaticInfo } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic'; import { PrimeIcons } from 'primereact/api'; -import { useCallback, useState } from 'react'; import { SystemInfoContent } from './SystemInfoContent'; +import { useState, useCallback } from 'react'; +import { CustomSystemSettingsDialog } from '@/hooks/Mapper/components/mapInterface/components/SystemSettingsDialog'; +import { ANOIK_ICON, DOTLAN_ICON, ZKB_ICON } from '@/hooks/Mapper/icons'; +import { getSystemStaticInfo } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic'; export const SystemInfo = () => { const [visible, setVisible] = useState(false); @@ -70,7 +70,7 @@ export const SystemInfo = () => { setVisible(true)} /> )} - {visible && } + {visible && } ); }; diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders/renderInfoColumn.tsx b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders/renderInfoColumn.tsx index 96f7ee65f..42e70fa2f 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders/renderInfoColumn.tsx +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders/renderInfoColumn.tsx @@ -10,6 +10,13 @@ import { parseSignatureCustomInfo } from '@/hooks/Mapper/helpers/parseSignatureC import clsx from 'clsx'; import { renderName } from './renderName.tsx'; +const renderSignatureNameOrGroup = (row: SystemSignature) => { + if (row.name && row.name !== 'Unknown') { + return renderName(row); + } + return row.group ? {row.group} : null; +}; + export const renderInfoColumn = (row: SystemSignature) => { if (!row.group || row.group === SignatureGroup.Wormhole) { const customInfo = parseSignatureCustomInfo(row.custom_info); @@ -18,7 +25,7 @@ export const renderInfoColumn = (row: SystemSignature) => { return (
      - {row.temporary_name && {row.temporary_name}} + {renderSignatureNameOrGroup(row)} {customInfo.time_status === TimeStatus._1h && ( @@ -98,7 +105,7 @@ export const renderInfoColumn = (row: SystemSignature) => { return (
      - {renderName(row)}{' '} + {renderSignatureNameOrGroup(row)}{' '} {row.description && ( diff --git a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemStructures/helpers/structureTypes.ts b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemStructures/helpers/structureTypes.ts index ca0a1c017..e19a1d95e 100644 --- a/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemStructures/helpers/structureTypes.ts +++ b/assets/js/hooks/Mapper/components/mapInterface/widgets/SystemStructures/helpers/structureTypes.ts @@ -12,6 +12,7 @@ export interface StructureItem { notes?: string; status: StructureStatus; endTime?: string; + inherited_from_map_id?: string | null; } export const STRUCTURE_TYPE_MAP: Record = { diff --git a/assets/js/hooks/Mapper/components/mapRootContent/MapRootContent.tsx b/assets/js/hooks/Mapper/components/mapRootContent/MapRootContent.tsx index 7b23404d2..cb3b84aa1 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/MapRootContent.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/MapRootContent.tsx @@ -12,6 +12,7 @@ import { CharacterActivity } from '@/hooks/Mapper/components/mapRootContent/comp import { WormholeSignaturesDialog } from '@/hooks/Mapper/components/mapRootContent/components/WormholeSignaturesDialog'; import { useCharacterActivityHandlers } from './hooks/useCharacterActivityHandlers'; import { TrackingDialog } from '@/hooks/Mapper/components/mapRootContent/components/TrackingDialog'; +import { FleetReadiness } from '@/hooks/Mapper/components/mapRootContent/components/FleetReadiness/FleetReadiness'; import { useMapEventListener } from '@/hooks/Mapper/events'; import { Commands } from '@/hooks/Mapper/types'; import { PingsInterface } from '@/hooks/Mapper/components/mapInterface/components'; @@ -36,6 +37,7 @@ export const MapRootContent = ({}: MapRootContentProps) => { const [showMapSettings, setShowMapSettings] = useState(false); const [showTrackingDialog, setShowTrackingDialog] = useState(false); const [showWormholeList, setShowWormholeList] = useState(false); + const [showFleetReadiness, setShowFleetReadiness] = useState(false); /* Important Notice - this solution needs for use one instance of MapInterface */ const mapInterface = isReady ? : null; @@ -44,6 +46,7 @@ export const MapRootContent = ({}: MapRootContentProps) => { const handleShowMapSettings = useCallback(() => setShowMapSettings(true), []); const handleShowTrackingDialog = useCallback(() => setShowTrackingDialog(true), []); const handleShowWormholesReference = useCallback(() => setShowWormholeList(true), []); + const handleShowFleetReadiness = useCallback(() => setShowFleetReadiness(true), []); useMapEventListener(event => { if (event.name === Commands.showTracking) { @@ -69,6 +72,7 @@ export const MapRootContent = ({}: MapRootContentProps) => { onShowMapSettings={handleShowMapSettings} onShowTrackingDialog={handleShowTrackingDialog} onShowWormholesReference={handleShowWormholesReference} + onShowFleetReadiness={handleShowFleetReadiness} additionalContent={} />
      @@ -84,6 +88,7 @@ export const MapRootContent = ({}: MapRootContentProps) => { onShowMapSettings={handleShowMapSettings} onShowTrackingDialog={handleShowTrackingDialog} onShowWormholesReference={handleShowWormholesReference} + onShowFleetReadiness={handleShowFleetReadiness} />
      @@ -101,6 +106,9 @@ export const MapRootContent = ({}: MapRootContentProps) => { setShowWormholeList(false)} /> {hasOldSettings && } + {showFleetReadiness && ( + setShowFleetReadiness(false)} /> + )}
      ); diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/FleetReadiness/FleetReadiness.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/FleetReadiness/FleetReadiness.tsx new file mode 100644 index 000000000..bcb0be8e9 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/FleetReadiness/FleetReadiness.tsx @@ -0,0 +1,139 @@ +import { Dialog } from 'primereact/dialog'; +import { FleetReadinessContent } from './FleetReadinessContent'; +import { useState, useCallback, useEffect } from 'react'; +import { Button } from 'primereact/button'; +import { PrimeIcons } from 'primereact/api'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { OutCommand } from '@/hooks/Mapper/types'; + +interface FleetReadinessProps { + visible: boolean; + onHide: () => void; +} + +interface RateLimitError { + error: string; + message: string; + remaining_cooldown: number; +} + +export const FleetReadiness = ({ visible, onHide }: FleetReadinessProps) => { + const { outCommand, data } = useMapRootState(); + const [isClearing, setIsClearing] = useState(false); + const [rateLimitInfo, setRateLimitInfo] = useState<{ + isRateLimited: boolean; + remainingCooldown: number; + message: string; + } | null>(null); + + // Derive ready count from global state + const readyCount = data.characters.filter(char => char.ready).length; + + const canClearAll = !isClearing && readyCount > 0 && !rateLimitInfo?.isRateLimited; + + const formatCooldownTime = (milliseconds: number) => { + const minutes = Math.floor(milliseconds / 60000); + const seconds = Math.floor((milliseconds % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + const handleClearAll = useCallback(async () => { + if (!canClearAll) return; + + setIsClearing(true); + setRateLimitInfo(null); + + try { + await outCommand({ + type: OutCommand.clearAllReadyCharacters, + data: {}, + }); + } catch (error: unknown) { + // Handle server-side rate limiting with runtime check + const isRateLimitError = (err: unknown): err is RateLimitError => { + return ( + typeof err === 'object' && + err !== null && + 'error' in err && + 'message' in err && + 'remaining_cooldown' in err && + (err as { error: unknown }).error === 'rate_limited' + ); + }; + + if (isRateLimitError(error)) { + setRateLimitInfo({ + isRateLimited: true, + remainingCooldown: error.remaining_cooldown || 0, + message: error.message || 'Clear all function is on cooldown', + }); + } else { + console.error('Failed to clear ready characters:', error); + } + } finally { + setIsClearing(false); + } + }, [canClearAll, outCommand]); + + // Update cooldown timer + useEffect(() => { + if (!rateLimitInfo?.isRateLimited) return; + + const timer = setInterval(() => { + setRateLimitInfo(prev => { + if (!prev) return null; + + const newCooldown = Math.max(0, prev.remainingCooldown - 100); + if (newCooldown === 0) { + return null; // Clear rate limit info when cooldown expires + } + + return { + ...prev, + remainingCooldown: newCooldown, + }; + }); + }, 100); + + return () => clearInterval(timer); + }, [rateLimitInfo?.isRateLimited]); + + const tooltipMessage = rateLimitInfo?.isRateLimited + ? `Clear (available in ${formatCooldownTime(rateLimitInfo.remainingCooldown)})` + : 'Clear'; + + const dialogHeader = ( +
      + {`Fleet Readiness (${readyCount})`} + {readyCount > 0 && ( +
      + ); + + return ( + + + + ); +}; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/FleetReadiness/FleetReadinessContent.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/FleetReadiness/FleetReadinessContent.tsx new file mode 100644 index 000000000..28aaf1e2b --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/FleetReadiness/FleetReadinessContent.tsx @@ -0,0 +1,169 @@ +import { DataTable } from 'primereact/datatable'; +import { Column } from 'primereact/column'; +import { useEffect, useState } from 'react'; +import { TrackingCharacter, OutCommand } from '@/hooks/Mapper/types'; +import { CharacterCard } from '@/hooks/Mapper/components/ui-kit'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { getSystemStaticInfo } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic'; +import { ProgressSpinner } from 'primereact/progressspinner'; + +const getRowClassName = () => ['text-xs', 'leading-tight']; + +const renderCharacterName = (character: TrackingCharacter) => { + return ( +
      + +
      + ); +}; + +const renderSystemLocation = (character: TrackingCharacter) => { + const char = character.character; + + if (!char.location?.solar_system_id) { + return Unknown location; + } + + const systemStaticInfo = getSystemStaticInfo(char.location.solar_system_id); + const systemName = systemStaticInfo?.solar_system_name || `System ${char.location.solar_system_id}`; + const isDocked = char.location.structure_id || char.location.station_id; + + return ( +
      + {systemName} + {isDocked && (Docked)} +
      + ); +}; + +const renderShipType = (character: TrackingCharacter) => { + const char = character.character; + + if (!char.ship?.ship_name) { + return Unknown ship; + } + + const shipTypeName = char.ship.ship_type_info?.name; + + return ( +
      + {shipTypeName || 'Unknown type'} + ({char.ship.ship_name}) +
      + ); +}; + +export const FleetReadinessContent = () => { + const { outCommand, data } = useMapRootState(); + const [allReadyCharacters, setAllReadyCharacters] = useState([]); + const [loading, setLoading] = useState(true); + + // Get ready character count from global state + const globalReadyCount = data.characters.filter(char => char.ready).length; + + // Use fetched detailed character data, but filter by global ready state when count changes + const readyCharacters = allReadyCharacters.filter(char => + data.characters.some(globalChar => globalChar.eve_id === char.character.eve_id && globalChar.ready), + ); + + useEffect(() => { + let isMounted = true; + + const loadAllReadyCharacters = async () => { + if (!isMounted) return; + setLoading(true); + + try { + const res = await outCommand({ + type: OutCommand.getAllReadyCharacters, + data: {}, + }); + + // Safe type checking instead of unsafe assertion + const isValidResponse = (response: unknown): response is { data?: { characters?: TrackingCharacter[] } } => { + return ( + typeof response === 'object' && + response !== null && + 'data' in response && + typeof response.data === 'object' && + response.data !== null && + (!('characters' in response.data) || Array.isArray((response.data as any).characters)) + ); + }; + + if (!isMounted) return; + + if (isValidResponse(res) && res.data && Array.isArray(res.data.characters)) { + setAllReadyCharacters(res.data.characters); + } else { + console.warn('Invalid response format for getAllReadyCharacters:', res); + setAllReadyCharacters([]); + } + } catch (err) { + console.error('Failed to load all ready characters:', err); + if (isMounted) { + setAllReadyCharacters([]); + } + } + + if (isMounted) { + setLoading(false); + } + }; + + loadAllReadyCharacters(); + + return () => { + isMounted = false; + }; + }, [outCommand, globalReadyCount]); // Re-fetch when global ready count changes + + if (loading) { + return ( +
      + +
      Loading Fleet Readiness...
      +
      + ); + } + + if (readyCharacters.length === 0) { + return ( +
      + No characters are currently marked as ready for combat. Characters must be online, tracked, and marked as ready + to appear here. +
      + Tip: Right-click character portraits in the top bar to mark them as ready. +
      +
      + ); + } + + return ( +
      + {/* Data Table */} +
      + + + + + +
      +
      + ); +}; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/MapContextMenu/MapContextMenu.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/MapContextMenu/MapContextMenu.tsx index b0a9a6084..4a20806b8 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/MapContextMenu/MapContextMenu.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/MapContextMenu/MapContextMenu.tsx @@ -13,6 +13,7 @@ export interface MapContextMenuProps { onShowMapSettings?: () => void; onShowTrackingDialog?: () => void; onShowWormholesReference?: () => void; + onShowFleetReadiness?: () => void; } export const MapContextMenu = ({ @@ -20,6 +21,7 @@ export const MapContextMenu = ({ onShowMapSettings, onShowTrackingDialog, onShowWormholesReference, + onShowFleetReadiness, }: MapContextMenuProps) => { const { outCommand, @@ -46,6 +48,12 @@ export const MapContextMenu = ({ command: onShowTrackingDialog, visible: canTrackCharacters, }, + { + label: 'Fleet Readiness', + icon: 'pi pi-users', + command: onShowFleetReadiness, + visible: canTrackCharacters, + }, { label: 'Character Activity', icon: 'pi pi-chart-bar', @@ -84,11 +92,13 @@ export const MapContextMenu = ({ ] as MenuItem[] ).filter(item => item.visible); }, [ - canTrackCharacters, onShowTrackingDialog, + canTrackCharacters, + onShowFleetReadiness, handleShowActivity, - onShowMapSettings, onShowOnTheMap, + onShowWormholesReference, + onShowMapSettings, setInterfaceSettings, ]); diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettings.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettings.tsx index 62611b24d..028fe9dc5 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettings.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettings.tsx @@ -25,11 +25,16 @@ export interface MapSettingsProps { export const MapSettingsComp = ({ visible, onHide }: MapSettingsProps) => { const [activeIndex, setActiveIndex] = useState(0); - const { outCommand } = useMapRootState(); + const { + outCommand, + data: { clientEnv }, + } = useMapRootState(); const { renderSettingItem, setUserRemoteSettings, settings } = useMapSettings(); const isAdmin = useMapCheckPermissions([UserPermission.ADMIN_MAP]); + const intelSharingEnabled = clientEnv?.intelSharingEnabled ?? false; + const refVars = useRef({ outCommand, onHide, visible }); refVars.current = { outCommand, onHide, visible }; @@ -109,7 +114,7 @@ export const MapSettingsComp = ({ visible, onHide }: MapSettingsProps) => { {isAdmin && ( - + )} diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/AdminSettings.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/AdminSettings.tsx index 93bb6ce03..d8565638e 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/AdminSettings.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/AdminSettings.tsx @@ -10,8 +10,13 @@ import { parseMapUserSettings } from '@/hooks/Mapper/components/helpers'; import fastDeepEqual from 'fast-deep-equal'; import { useDetectSettingsChanged } from '@/hooks/Mapper/components/hooks'; import { WdButton } from '@/hooks/Mapper/components/ui-kit'; +import { IntelSettings } from './IntelSettings.tsx'; -export const AdminSettings = () => { +interface AdminSettingsProps { + intelSharingEnabled?: boolean; +} + +export const AdminSettings = ({ intelSharingEnabled = false }: AdminSettingsProps) => { const { storedSettings: { getSettingsForExport }, outCommand, @@ -90,27 +95,30 @@ export const AdminSettings = () => { return (
      -
      -
      - +
      + Default Settings +
      +
      + +
      + + {!isDirty && *Local and remote are identical.} + + + *Will save your current settings as the default for all new users of this map. This action will overwrite + any existing default settings. +
      - - {!isDirty && *Local and remote are identical.} - - - *Will save your current settings as the default for all new users of this map. This action will overwrite any - existing default settings. -
      @@ -123,6 +131,16 @@ export const AdminSettings = () => { icon="pi pi-exclamation-triangle" accept={handleSync} /> + + {intelSharingEnabled && ( + <> +
      +
      + Intel Source + +
      + + )}
      ); }; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/IntelSettings.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/IntelSettings.tsx new file mode 100644 index 000000000..820405529 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/components/IntelSettings.tsx @@ -0,0 +1,122 @@ +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { OutCommand, UserPermission } from '@/hooks/Mapper/types'; +import { useMapCheckPermissions } from '@/hooks/Mapper/mapRootProvider/hooks/api'; +import { Dropdown } from 'primereact/dropdown'; +import { WdButton } from '@/hooks/Mapper/components/ui-kit'; + +interface IntelSourceMap { + id: string; + name: string; + slug: string; +} + +export const IntelSettings = () => { + const { + outCommand, + data: { options }, + } = useMapRootState(); + + const isManager = useMapCheckPermissions([UserPermission.MANAGE_MAP]); + const isAdmin = useMapCheckPermissions([UserPermission.ADMIN_MAP]); + const hasPermission = isManager || isAdmin; + + const [availableMaps, setAvailableMaps] = useState([]); + const [selectedMapId, setSelectedMapId] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + setLoading(true); + try { + const result = (await outCommand({ + type: OutCommand.getIntelSourceMaps, + data: null, + })) as { maps?: IntelSourceMap[] } | undefined; + + if (!cancelled && result?.maps) { + setAvailableMaps(result.maps); + } + } catch (error) { + console.error('Failed to load intel source maps:', error); + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + if (hasPermission) { + load(); + } + + return () => { + cancelled = true; + }; + }, [outCommand, hasPermission]); + + useEffect(() => { + setSelectedMapId(options?.intel_source_map_id ?? null); + }, [options?.intel_source_map_id]); + + const dropdownOptions = useMemo(() => availableMaps.map(m => ({ label: m.name, value: m.id })), [availableMaps]); + + const handleChange = useCallback( + async (mapId: string | null) => { + setSelectedMapId(mapId); + try { + await outCommand({ + type: OutCommand.setIntelSourceMap, + data: { intel_source_map_id: mapId }, + }); + } catch (error) { + console.error('Failed to update intel source map:', error); + setSelectedMapId(options?.intel_source_map_id ?? null); + } + }, + [outCommand, options?.intel_source_map_id], + ); + + const handleClear = useCallback(() => { + handleChange(null); + }, [handleChange]); + + if (!hasPermission) { + return null; + } + + return ( +
      + + Select a map to use as the intel source. System intel (custom names, labels, descriptions, status, comments, + structures) will be copied from the source map when systems appear on this map. + + + {/* 1fr_auto grid: default `stretch` sizes Clear to the row, and py-0 keeps the + dropdown (not the button) the taller item, so the row is the dropdown's height */} +
      + handleChange(e.value)} + placeholder="Select intel source map" + className="w-full" + loading={loading} + showClear={false} + /> + + +
      +
      + ); +}; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/constants.ts b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/constants.ts index d4369d4fa..273ec9ac9 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/constants.ts +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/MapSettings/constants.ts @@ -151,6 +151,7 @@ export const THEME_OPTIONS = [ { label: 'Default', value: AvailableThemes.default }, { label: 'Default Large', value: AvailableThemes.accessibleLarge }, { label: 'Pathfinder', value: AvailableThemes.pathfinder }, + { label: 'Faoble', value: AvailableThemes.zoo }, { label: 'High-contrast', value: AvailableThemes.accessibleDark }, { label: 'High-contrast Large', value: AvailableThemes.accessibleLargeColorblind }, ]; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/RightBar/RightBar.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/RightBar/RightBar.tsx index 50a860afe..5acce6714 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/RightBar/RightBar.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/RightBar/RightBar.tsx @@ -15,6 +15,7 @@ interface RightBarProps { onShowMapSettings?: () => void; onShowTrackingDialog?: () => void; onShowWormholesReference?: () => void; + onShowFleetReadiness?: () => void; additionalContent?: ReactNode; } @@ -23,6 +24,7 @@ export const RightBar = ({ onShowMapSettings, onShowTrackingDialog, onShowWormholesReference, + onShowFleetReadiness, additionalContent, }: RightBarProps) => { const { @@ -68,6 +70,17 @@ export const RightBar = ({ + + + +
      ( diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx index 4cd3b6cbb..16e38bdf1 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/SignatureSettings.tsx @@ -69,6 +69,8 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map k162Type: values.k162Type, time_status: values.time_status, mass_status: values.mass_status, + isEOL: values.isEOL, + isCrit: values.isCrit, }), }; @@ -214,12 +216,16 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map let k162Type: string | undefined = undefined; let time_status = TimeStatus._24h; let mass_status = MassState.normal; + let isEOL = false; + let isCrit = false; if (custom_info) { const customInfo = parseSignatureCustomInfo(custom_info); destType = customInfo.destType; k162Type = customInfo.k162Type; time_status = customInfo.time_status ?? TimeStatus._24h; mass_status = customInfo.mass_status ?? MassState.normal; + isEOL = customInfo.isEOL || false; + isCrit = customInfo.isCrit || false; } signatureForm.reset({ @@ -228,6 +234,8 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map k162Type: k162Type, time_status: time_status, mass_status: mass_status, + isEOL: isEOL, + isCrit: isCrit, ...rest, }); }, [signatureForm, signatureData]); diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureCriticalCheckbox/SignatureCriticalCheckbox.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureCriticalCheckbox/SignatureCriticalCheckbox.tsx new file mode 100644 index 000000000..579aa8cdb --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureCriticalCheckbox/SignatureCriticalCheckbox.tsx @@ -0,0 +1,24 @@ +import { InputSwitch } from 'primereact/inputswitch'; +import { Controller, useFormContext } from 'react-hook-form'; +import { SystemSignature } from '@/hooks/Mapper/types'; + +export interface SignatureCriticalCheckboxProps { + name: string; + defaultValue?: boolean; +} + +export const SignatureCriticalCheckbox = ({ name, defaultValue = false }: SignatureCriticalCheckboxProps) => { + const { control } = useFormContext(); + + return ( + { + return field.onChange(e.value)} />; + }} + /> + ); +}; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureCriticalCheckbox/index.ts b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureCriticalCheckbox/index.ts new file mode 100644 index 000000000..e2e5dbf59 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureCriticalCheckbox/index.ts @@ -0,0 +1 @@ +export * from './SignatureCriticalCheckbox.tsx'; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureGroupContentWormholes.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureGroupContentWormholes.tsx index 3a5931fda..80035a1fc 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureGroupContentWormholes.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureGroupContentWormholes.tsx @@ -6,6 +6,7 @@ import { SignatureLeadsToSelect } from '@/hooks/Mapper/components/mapRootContent import { SignatureLifetimeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLifetimeSelect.tsx'; import { SignatureTempName } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureTempName.tsx'; import { SignatureMassStatusSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureMassStatusSelect.tsx'; +import { SignatureCriticalCheckbox } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureCriticalCheckbox'; import { MULTI_DEST_WHS } from '@/hooks/Mapper/constants'; export const SignatureGroupContentWormholes = () => { @@ -44,6 +45,8 @@ export const SignatureGroupContentWormholes = () => { ); diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect/SignatureLeadsToSelect.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect/SignatureLeadsToSelect.tsx index 30481928f..2da248c9b 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect/SignatureLeadsToSelect.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect/SignatureLeadsToSelect.tsx @@ -8,7 +8,7 @@ import { SystemView } from '@/hooks/Mapper/components/ui-kit'; import classes from './SignatureLeadsToSelect.module.scss'; import { useLoadSystemStatic } from '@/hooks/Mapper/mapRootProvider/hooks/useLoadSystemStatic.ts'; import { SystemSignature } from '@/hooks/Mapper/types'; -import { WORMHOLES_ADDITIONAL_INFO_BY_CLASS_ID } from '@/hooks/Mapper/components/map/constants.ts'; +import { WORMHOLES_ADDITIONAL_INFO_BY_CLASS_ID } from '@/hooks/Mapper/components/map/constants'; import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; // @ts-ignore diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureWormholeTypeSelect/SignatureWormholeTypeSelect.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureWormholeTypeSelect/SignatureWormholeTypeSelect.tsx index bff451d96..ae3564ff1 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureWormholeTypeSelect/SignatureWormholeTypeSelect.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureWormholeTypeSelect/SignatureWormholeTypeSelect.tsx @@ -8,7 +8,7 @@ import { useSystemInfo } from '@/hooks/Mapper/components/hooks'; import { SOLAR_SYSTEM_CLASSES_TO_CLASS_GROUPS, WORMHOLES_ADDITIONAL_INFO_BY_CLASS_ID, -} from '@/hooks/Mapper/components/map/constants.ts'; +} from '@/hooks/Mapper/components/map/constants'; import { useMemo } from 'react'; import { WHClassView } from '@/hooks/Mapper/components/ui-kit'; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/ReadyCharactersList.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/ReadyCharactersList.tsx new file mode 100644 index 000000000..650aa3bf3 --- /dev/null +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/ReadyCharactersList.tsx @@ -0,0 +1,83 @@ +import React, { useCallback } from 'react'; +import { TrackingCharacter } from '@/hooks/Mapper/types'; + +interface ReadyCharactersListProps { + trackingCharacters: TrackingCharacter[]; + ready: string[]; + onReadyChange: (characterId: string, isReady: boolean) => void; +} + +export const ReadyCharactersList = ({ trackingCharacters, ready, onReadyChange }: ReadyCharactersListProps) => { + const offlineCount = trackingCharacters.filter(({ character }) => !character.online).length; + const availableCharacters = trackingCharacters.filter(({ character }) => character.online); + + const handleCheckboxChange = useCallback( + (id: string, checked: boolean) => onReadyChange(id, checked), + [onReadyChange], + ); + + return ( +
      + {availableCharacters.length === 0 ? ( +
      + {offlineCount > 0 ? ( + <> +

      No online characters to select.

      +

      + {offlineCount} offline character{offlineCount !== 1 ? 's' : ''} hidden +

      + + ) : ( +

      No characters available.

      + )} +
      + ) : ( +
      + {availableCharacters.map(({ character }) => { + const { eve_id, name, ship } = character; + const isReady = ready.includes(eve_id); + const shipTypeName = ship?.ship_type_info?.name; + const shipName = ship?.ship_name; + const shipInfo = shipTypeName ? `${shipTypeName} (${shipName})` : shipName || 'Unknown ship'; + + return ( + + ); + })} +
      + )} +
      + ); +}; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingDialog.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingDialog.tsx index 354131079..a8fd848ad 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingDialog.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingDialog.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Dialog } from 'primereact/dialog'; import { TabPanel, TabView } from 'primereact/tabview'; import { TrackingSettings } from './TrackingSettings.tsx'; import { TrackingCharactersList } from './TrackingCharactersList.tsx'; -import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { ReadyCharactersList } from './ReadyCharactersList.tsx'; import { TrackingProvider, useTracking } from './TrackingProvider.tsx'; interface TrackingDialogProps { @@ -11,34 +11,38 @@ interface TrackingDialogProps { onHide: () => void; } -const TrackingDialogComp = ({ visible, onHide }: TrackingDialogProps) => { +const TrackingDialogContent = ({ visible, onHide }: TrackingDialogProps) => { const [activeIndex, setActiveIndex] = useState(0); - const { outCommand } = useMapRootState(); - const { loadTracking } = useTracking(); - - const refVars = useRef({ outCommand }); - refVars.current = { outCommand }; + const { loadTracking, trackingCharacters, ready, updateReady } = useTracking(); useEffect(() => { - if (!visible) { - return; + if (visible) { + loadTracking(); } + }, [visible, loadTracking]); - loadTracking(); - }, [loadTracking, visible]); + const handleReadyChange = useCallback( + (characterId: string, isReady: boolean) => { + if (isReady) { + if (ready.includes(characterId)) { + return; + } + updateReady([...ready, characterId]); + return; + } + updateReady(ready.filter(id => id !== characterId)); + }, + [ready, updateReady], + ); return ( - Track & Follow -
      - } + header={
      Track & Follow
      } draggable={false} resizable={false} visible={visible} onHide={onHide} - className="w-[640px] h-[600px] text-text-color min-h-0" + className="w-[640px] h-[400px] min-h-0 text-text-color" > { - + + + + ); @@ -60,7 +71,7 @@ const TrackingDialogComp = ({ visible, onHide }: TrackingDialogProps) => { export const TrackingDialog = (props: TrackingDialogProps) => { return ( - + ); }; diff --git a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingProvider.tsx b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingProvider.tsx index d61802769..6b02a517e 100644 --- a/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingProvider.tsx +++ b/assets/js/hooks/Mapper/components/mapRootContent/components/TrackingDialog/TrackingProvider.tsx @@ -1,20 +1,38 @@ -import { createContext, useCallback, useContext, useRef, useState } from 'react'; -import { Commands, OutCommand, TrackingCharacter } from '@/hooks/Mapper/types'; +import { createContext, useCallback, useContext, useRef, useState, useEffect, useMemo } 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 }; +interface UpdateReadyResponse { + data?: unknown; + error?: string; + message?: string; + remaining_cooldown?: number; +} + +// Type guard to check if response is an UpdateReadyResponse with error +function isUpdateReadyResponseWithError(response: unknown): response is UpdateReadyResponse & { error: string } { + return ( + typeof response === 'object' && + response !== null && + 'error' in response && + typeof (response as UpdateReadyResponse).error === 'string' + ); +} + type TrackingContextType = { loadTracking: () => void; updateTracking: (selected: string[]) => void; updateFollowing: (characterId: string | null) => void; updateMain: (characterId: string) => void; + updateReady: (readyCharacterIds: string[]) => Promise; trackingCharacters: TrackingCharacter[]; following: string | null; main: string | null; + ready: string[]; loading: boolean; }; @@ -24,12 +42,40 @@ export const TrackingProvider = ({ children }: WithChildren) => { const [trackingCharacters, setTrackingCharacters] = useState([]); const [following, setFollowing] = useState(null); const [main, setMain] = useState(null); + const [ready, setReady] = useState([]); const [loading, setLoading] = useState(false); - const { outCommand } = useMapRootState(); + const { outCommand, data } = useMapRootState(); const refVars = useRef({ outCommand, trackingCharacters, following }); refVars.current = { outCommand, trackingCharacters, following }; + // Memoize the ready characters array to avoid recalculations + const globalReadyCharacters = useMemo(() => { + return data.characters?.filter(char => char.ready)?.map(char => char.eve_id) || []; + }, [data.characters]); + + // Sync ready state with global character data - only update if values actually changed + useEffect(() => { + setReady(prevReady => { + // Only update if the arrays are different + if ( + prevReady.length !== globalReadyCharacters.length || + !prevReady.every(id => globalReadyCharacters.includes(id)) + ) { + return globalReadyCharacters; + } + return prevReady; + }); + + // Also update the ready status in trackingCharacters + setTrackingCharacters(prev => + prev.map(trackingChar => ({ + ...trackingChar, + ready: globalReadyCharacters.includes(trackingChar.character.eve_id), + })), + ); + }, [globalReadyCharacters]); + const loadTracking = useCallback(async () => { setLoading(true); @@ -39,9 +85,10 @@ export const TrackingProvider = ({ children }: WithChildren) => { data: {}, }); - setTrackingCharacters(res.data.characters); + setTrackingCharacters(res.data.characters || []); setFollowing(res.data.following); setMain(res.data.main); + setReady(res.data.ready_characters || []); } catch (err) { console.error('TrackingProviderError', err); } @@ -93,6 +140,7 @@ export const TrackingProvider = ({ children }: WithChildren) => { return { tracked: selected.includes(x.character.eve_id), character: x.character, + ready: x.ready, }; }); @@ -123,13 +171,38 @@ 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; - } - }); + const updateReady = useCallback( + async (readyCharacterIds: string[]) => { + try { + const response = await outCommand({ + type: OutCommand.updateReadyCharacters, + data: { ready_character_eve_ids: readyCharacterIds }, + }); + + // Check if the response indicates a rate limit error + if (isUpdateReadyResponseWithError(response)) { + throw response; + } + + // Update local state immediately + setReady(readyCharacterIds); + + // Also update trackingCharacters to reflect ready status + setTrackingCharacters(prev => + prev.map(char => ({ + ...char, + ready: readyCharacterIds.includes(char.character.eve_id), + })), + ); + + return response; + } catch (error) { + console.error('Error updating ready characters:', error); + throw error; + } + }, + [outCommand], + ); return ( { trackingCharacters, following, main, + ready, loading, updateTracking, updateFollowing, updateMain, + updateReady, }} > {children} diff --git a/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx b/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx index d64eb5c46..e6b0761a6 100644 --- a/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx +++ b/assets/js/hooks/Mapper/components/mapWrapper/MapWrapper.tsx @@ -4,7 +4,7 @@ import { OnMapAddSystemCallback, OnMapSelectionChange } from '@/hooks/Mapper/com import { SystemCustomLabelDialog, SystemLinkSignatureDialog, - SystemSettingsDialog, + CustomSystemSettingsDialog, } from '@/hooks/Mapper/components/mapInterface/components'; import { Connections } from '@/hooks/Mapper/components/mapRootContent/components/Connections'; import { getSystemById } from '@/hooks/Mapper/helpers'; @@ -288,7 +288,7 @@ export const MapWrapper = () => { /> {openSettings != null && ( - setOpenSettings(null)} /> + setOpenSettings(null)} /> )} {openPing != null && ( , statics: string[]) => { diff --git a/assets/js/hooks/Mapper/hooks/useRallyRoute.ts b/assets/js/hooks/Mapper/hooks/useRallyRoute.ts new file mode 100644 index 000000000..f4e07d030 --- /dev/null +++ b/assets/js/hooks/Mapper/hooks/useRallyRoute.ts @@ -0,0 +1,183 @@ +import { useMemo } from 'react'; +import { useMapRootState } from '@/hooks/Mapper/mapRootProvider'; +import { SolarSystemConnection } from '@/hooks/Mapper/types'; + +export interface RallyRouteData { + // Systems that are part of the rally route + highlightedSystems: Set; + // Connections that are part of the rally route + highlightedConnections: Set; + // Whether a rally route is active + isActive: boolean; + // The rally point system ID + rallySystemId: string | null; + // The followed character's current system ID + followedCharacterSystemId: string | null; +} + +/** + * Hook to calculate and provide data for highlighting the route from + * the followed character to the active rally point + */ +export function useRallyRoute(): RallyRouteData { + const { + data: { + followingCharacterEveId, + characters, + pings, + systems, + connections + }, + } = useMapRootState(); + + return useMemo(() => { + // Find the active rally point (type 1) + const rallyPing = pings.find(ping => ping.type === 1); + + if (!rallyPing) { + return { + highlightedSystems: new Set(), + highlightedConnections: new Set(), + isActive: false, + rallySystemId: null, + followedCharacterSystemId: null, + }; + } + + // Find the followed character - try both string and number comparison + const followedCharacter = characters.find( + char => char.eve_id === String(followingCharacterEveId) || + char.eve_id === followingCharacterEveId || + String(char.eve_id) === String(followingCharacterEveId) + ); + + + // We'll show the route even if the character is offline, as long as they have a location + if (!followedCharacter || !followedCharacter.location || !followedCharacter.location.solar_system_id) { + return { + highlightedSystems: new Set(), + highlightedConnections: new Set(), + isActive: false, + rallySystemId: rallyPing.solar_system_id, + followedCharacterSystemId: null, + }; + } + + const followedCharacterSystemId = followedCharacter.location.solar_system_id.toString(); + + // If the followed character is already at the rally point + if (followedCharacterSystemId === rallyPing.solar_system_id) { + return { + highlightedSystems: new Set([rallyPing.solar_system_id]), + highlightedConnections: new Set(), + isActive: true, + rallySystemId: rallyPing.solar_system_id, + followedCharacterSystemId: followedCharacterSystemId, + }; + } + + // Calculate the route using BFS (Breadth-First Search) + const route = findRoute( + followedCharacterSystemId, + rallyPing.solar_system_id, + connections, + systems.map(s => s.id) + ); + + if (!route) { + return { + highlightedSystems: new Set(), + highlightedConnections: new Set(), + isActive: false, + rallySystemId: rallyPing.solar_system_id, + followedCharacterSystemId: followedCharacterSystemId, + }; + } + + // Create sets for highlighted systems and connections + const highlightedSystems = new Set(route.path); + const highlightedConnections = new Set(); + + // Add connections to the highlighted set + for (let i = 0; i < route.path.length - 1; i++) { + const source = route.path[i]; + const target = route.path[i + 1]; + + // Find the connection between these systems + const connection = connections.find( + conn => + (conn.source === source && conn.target === target) || + (conn.source === target && conn.target === source) + ); + + if (connection) { + // Create a normalized connection ID + const connectionId = [connection.source, connection.target].sort().join('-'); + highlightedConnections.add(connectionId); + } + } + + return { + highlightedSystems, + highlightedConnections, + isActive: true, + rallySystemId: rallyPing.solar_system_id, + followedCharacterSystemId: followedCharacterSystemId, + }; + }, [followingCharacterEveId, characters, pings, systems, connections]); +} + +/** + * Find the shortest route between two systems using BFS + */ +function findRoute( + startSystemId: string, + endSystemId: string, + connections: SolarSystemConnection[], + validSystems: string[] +): { path: string[] } | null { + // Build adjacency list + const adjacencyList = new Map(); + + for (const system of validSystems) { + adjacencyList.set(system, []); + } + + for (const connection of connections) { + const sourceList = adjacencyList.get(connection.source); + const targetList = adjacencyList.get(connection.target); + + if (sourceList && targetList) { + sourceList.push(connection.target); + targetList.push(connection.source); + } + } + + // BFS to find shortest path + const queue: { systemId: string; path: string[] }[] = [ + { systemId: startSystemId, path: [startSystemId] } + ]; + const visited = new Set([startSystemId]); + + while (queue.length > 0) { + const current = queue.shift()!; + + if (current.systemId === endSystemId) { + return { path: current.path }; + } + + const neighbors = adjacencyList.get(current.systemId) || []; + + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push({ + systemId: neighbor, + path: [...current.path, neighbor] + }); + } + } + } + + return null; +} \ No newline at end of file diff --git a/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx b/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx index eb3106c78..d65d044c8 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx +++ b/assets/js/hooks/Mapper/mapRootProvider/MapRootProvider.tsx @@ -104,6 +104,7 @@ const INITIAL_DATA: MapRootData = { loadingPublicRoutes: false, map_slug: null, expiredCharacters: [], + clientEnv: { intelSharingEnabled: false, detailedKillsDisabled: false }, }; export enum InterfaceStoredSettingsProps { diff --git a/assets/js/hooks/Mapper/mapRootProvider/constants.ts b/assets/js/hooks/Mapper/mapRootProvider/constants.ts index 4a2c4a5e4..a6403e80c 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/constants.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/constants.ts @@ -13,13 +13,13 @@ import { import { DEFAULT_WIDGETS, STORED_VISIBLE_WIDGETS_DEFAULT } from '@/hooks/Mapper/components/mapInterface/constants.tsx'; export const STORED_INTERFACE_DEFAULT_VALUES: InterfaceStoredSettings = { - isShowMenu: false, - isShowKSpace: false, + isShowMenu: true, + isShowKSpace: true, isThickConnections: false, isShowUnsplashedSignatures: false, isShowBackgroundPattern: true, - isSoftBackground: false, - theme: AvailableThemes.default, + isSoftBackground: true, + theme: AvailableThemes.zoo, pingsPlacement: PingsPlacement.rightTop, minimapPlacement: MiniMapPlacement.rightBottom, hideBookmarkWarning: false, diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useCommandsCharacters.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useCommandsCharacters.ts index 777066225..6a5cff826 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useCommandsCharacters.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useCommandsCharacters.ts @@ -5,6 +5,8 @@ import { CommandCharactersUpdated, CommandCharacterUpdated, CommandPresentCharacters, + CommandReadyCharactersUpdated, + CommandAllReadyCharactersCleared, } from '@/hooks/Mapper/types'; import { useCallback, useRef } from 'react'; @@ -51,7 +53,10 @@ export const useCommandsCharacters = () => { const characterUpdated = useCallback((value: CommandCharacterUpdated) => { ref.current.update(state => { - return { characters: [...state.characters.filter(x => x.eve_id !== value.eve_id), value] }; + const existingCharacter = state.characters.find(x => x.eve_id === value.eve_id); + const updatedCharacter = + existingCharacter && value.ready === undefined ? { ...value, ready: existingCharacter.ready } : value; + return { characters: [...state.characters.filter(x => x.eve_id !== value.eve_id), updatedCharacter] }; }); }, []); @@ -59,5 +64,38 @@ export const useCommandsCharacters = () => { ref.current.update(() => ({ presentCharacters: value })); }, []); - return { charactersUpdated, characterAdded, characterRemoved, characterUpdated, presentCharacters }; + const readyCharactersUpdated = useCallback((value: CommandReadyCharactersUpdated) => { + const { ready_character_eve_ids } = value; + ref.current.update(state => ({ + characters: state.characters.map(char => ({ + ...char, + ready: ready_character_eve_ids.includes(char.eve_id), + })), + })); + }, []); + + const allReadyCharactersCleared = useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_value: CommandAllReadyCharactersCleared) => { + // Clear all ready status for all characters + // Note: _value contains cleared_by_user_id but we don't need it for this operation + ref.current.update(state => ({ + characters: state.characters.map(char => ({ + ...char, + ready: false, + })), + })); + }, + [], + ); + + return { + charactersUpdated, + characterAdded, + characterRemoved, + characterUpdated, + presentCharacters, + readyCharactersUpdated, + allReadyCharactersCleared, + }; }; diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapInit.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapInit.ts index 3697827c5..94cfc7fe4 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapInit.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/api/useMapInit.ts @@ -30,6 +30,7 @@ export const useMapInit = () => { user_hubs, map_slug, expired_characters, + client_env, } = props; const updateData: Partial = {}; @@ -113,6 +114,10 @@ export const useMapInit = () => { updateData.expiredCharacters = expired_characters; } + if ('client_env' in props) { + updateData.clientEnv = client_env; + } + update(updateData); }, [update, addSystemStatic], diff --git a/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts b/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts index 56f68b176..9e896b045 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/hooks/useMapRootHandlers.ts @@ -25,6 +25,8 @@ import { CommandUpdateSystems, CommandUserSettingsUpdated, MapHandlers, + CommandReadyCharactersUpdated, + CommandAllReadyCharactersCleared, } from '@/hooks/Mapper/types/mapHandlers.ts'; import { ForwardedRef, useImperativeHandle } from 'react'; @@ -57,8 +59,15 @@ export const useMapRootHandlers = (ref: ForwardedRef) => { updateDetailedKills, } = useCommandsSystems(); const { addConnections, removeConnections, updateConnection } = useCommandsConnections(); - const { charactersUpdated, characterAdded, characterRemoved, characterUpdated, presentCharacters } = - useCommandsCharacters(); + const { + charactersUpdated, + characterAdded, + characterRemoved, + characterUpdated, + presentCharacters, + readyCharactersUpdated, + allReadyCharactersCleared, + } = useCommandsCharacters(); const mapUpdated = useMapUpdated(); const mapRoutes = useRoutes(); const mapUserRoutes = useUserRoutes(); @@ -183,6 +192,15 @@ export const useMapRootHandlers = (ref: ForwardedRef) => { case Commands.pingBlocked: pingBlocked(data as CommandPingBlocked); break; + + case Commands.readyCharactersUpdated: + readyCharactersUpdated(data as CommandReadyCharactersUpdated); + break; + + case Commands.allReadyCharactersCleared: + allReadyCharactersCleared(data as CommandAllReadyCharactersCleared); + break; + default: console.warn(`JOipP Interface handlers: Unknown command: ${type}`, data); break; @@ -191,5 +209,35 @@ export const useMapRootHandlers = (ref: ForwardedRef) => { emitMapEvent({ name: type, data }); }, }; - }, []); + }, [ + addComment, + addConnections, + addSystems, + allReadyCharactersCleared, + characterActivityData, + characterAdded, + characterRemoved, + characterUpdated, + charactersUpdated, + mapInit, + mapRoutes, + mapRoutesListBy, + mapUpdated, + mapUserRoutes, + pingAdded, + pingBlocked, + pingCancelled, + presentCharacters, + readyCharactersUpdated, + removeComment, + removeConnections, + removeSystems, + trackingCharactersData, + updateConnection, + updateDetailedKills, + updateLinkSignatureToSystem, + updateSystemSignatures, + updateSystems, + userSettingsUpdated, + ]); }; diff --git a/assets/js/hooks/Mapper/mapRootProvider/types.ts b/assets/js/hooks/Mapper/mapRootProvider/types.ts index 923152417..5a911e29d 100644 --- a/assets/js/hooks/Mapper/mapRootProvider/types.ts +++ b/assets/js/hooks/Mapper/mapRootProvider/types.ts @@ -4,6 +4,7 @@ import { SignatureSettingsType } from '@/hooks/Mapper/constants/signatures.ts'; export enum AvailableThemes { default = 'default', pathfinder = 'pathfinder', + zoo = 'zoo', accessibleDark = 'accessible-dark', accessibleLarge = 'accessible-large', accessibleLargeColorblind = 'accessible-large-colorblind', diff --git a/assets/js/hooks/Mapper/types/character.ts b/assets/js/hooks/Mapper/types/character.ts index a9aa11d06..27e91e05c 100644 --- a/assets/js/hooks/Mapper/types/character.ts +++ b/assets/js/hooks/Mapper/types/character.ts @@ -33,11 +33,14 @@ export type CharacterTypeRaw = { corporation_id: number; corporation_name: string; corporation_ticker: string; + tracking_paused: boolean; + ready?: boolean; }; export interface TrackingCharacter { character: CharacterTypeRaw; tracked: boolean; + ready: boolean; } export type WithIsOwnCharacter = { diff --git a/assets/js/hooks/Mapper/types/commandsIn.ts b/assets/js/hooks/Mapper/types/commandsIn.ts index d3ce806e8..1d02b4ca0 100644 --- a/assets/js/hooks/Mapper/types/commandsIn.ts +++ b/assets/js/hooks/Mapper/types/commandsIn.ts @@ -4,4 +4,5 @@ export type CommandInCharactersTrackingInfo = { characters: TrackingCharacter[]; following: string | null; main: string | null; + ready_characters: string[]; }; diff --git a/assets/js/hooks/Mapper/types/comment.ts b/assets/js/hooks/Mapper/types/comment.ts index c28577c4f..5c1b0d023 100644 --- a/assets/js/hooks/Mapper/types/comment.ts +++ b/assets/js/hooks/Mapper/types/comment.ts @@ -4,6 +4,7 @@ export type CommentType = { solarSystemId: number; text: string; updated_at: string; + inherited_from_map_id?: string | null; }; export type CommentSystem = { diff --git a/assets/js/hooks/Mapper/types/connection.ts b/assets/js/hooks/Mapper/types/connection.ts index 036e2527e..b260c46e9 100644 --- a/assets/js/hooks/Mapper/types/connection.ts +++ b/assets/js/hooks/Mapper/types/connection.ts @@ -2,6 +2,7 @@ export enum ConnectionType { wormhole, gate, bridge, + loop, } export enum MassState { diff --git a/assets/js/hooks/Mapper/types/mapHandlers.ts b/assets/js/hooks/Mapper/types/mapHandlers.ts index 64a3c3f62..01f027a94 100644 --- a/assets/js/hooks/Mapper/types/mapHandlers.ts +++ b/assets/js/hooks/Mapper/types/mapHandlers.ts @@ -44,6 +44,8 @@ export enum Commands { pingAdded = 'ping_added', pingCancelled = 'ping_cancelled', pingBlocked = 'ping_blocked', + readyCharactersUpdated = 'ready_characters_updated', + allReadyCharactersCleared = 'all_ready_characters_cleared', } export type Command = @@ -82,7 +84,14 @@ export type Command = | Commands.refreshTrackingData | Commands.pingAdded | Commands.pingCancelled - | Commands.pingBlocked; + | Commands.pingBlocked + | Commands.readyCharactersUpdated + | Commands.allReadyCharactersCleared; + +export type ClientEnv = { + intelSharingEnabled: boolean; + detailedKillsDisabled: boolean; +}; export type CommandInit = { systems: SolarSystemRawType[]; @@ -109,6 +118,7 @@ export type CommandInit = { following_character_eve_id?: string | null; map_slug?: string; expired_characters: string[]; + client_env?: ClientEnv; }; export type CommandAddSystems = SolarSystemRawType[]; @@ -172,6 +182,17 @@ export type CommandPingCancelled = Pick; export type CommandPingBlocked = { reason: string; message: string; +}; +export type CommandUpdateReadyCharacters = { + ready_character_eve_ids: string[]; +}; +export type CommandReadyCharactersUpdated = { + user_id: string; + user_name: string; + ready_character_eve_ids: string[]; +}; +export type CommandAllReadyCharactersCleared = { + cleared_by_user_id: string; }; export interface UserSettings { @@ -226,6 +247,8 @@ export interface CommandData { [Commands.pingAdded]: CommandPingAdded; [Commands.pingCancelled]: CommandPingCancelled; [Commands.pingBlocked]: CommandPingBlocked; + [Commands.readyCharactersUpdated]: CommandReadyCharactersUpdated; + [Commands.allReadyCharactersCleared]: CommandAllReadyCharactersCleared; } export interface MapHandlers { @@ -256,6 +279,7 @@ export enum OutCommand { updateSignatures = 'update_signatures', updateSystemName = 'update_system_name', updateSystemTemporaryName = 'update_system_temporary_name', + updateSystemOwner = 'update_system_owner', updateSystemDescription = 'update_system_description', updateSystemLabels = 'update_system_labels', updateSystemLocked = 'update_system_locked', @@ -287,10 +311,20 @@ export enum OutCommand { updateCharacterTracking = 'updateCharacterTracking', updateFollowingCharacter = 'updateFollowingCharacter', updateMainCharacter = 'updateMainCharacter', + updateReadyCharacters = 'updateReadyCharacters', + getAllReadyCharacters = 'getAllReadyCharacters', + clearAllReadyCharacters = 'clearAllReadyCharacters', addPing = 'add_ping', cancelPing = 'cancel_ping', startTracking = 'startTracking', + getIntelSourceMaps = 'get_intel_source_maps', + setIntelSourceMap = 'set_intel_source_map', + syncIntel = 'sync_intel', + + updateSystemCustomFlags = 'update_system_custom_flags', + getAllianceNames = 'get_alliance_names', + getAllianceTicker = 'get_alliance_ticker', // Only UI commands openSettings = 'open_settings', showActivity = 'show_activity', diff --git a/assets/js/hooks/Mapper/types/mapUnionTypes.ts b/assets/js/hooks/Mapper/types/mapUnionTypes.ts index 6d0f949e0..184a89738 100644 --- a/assets/js/hooks/Mapper/types/mapUnionTypes.ts +++ b/assets/js/hooks/Mapper/types/mapUnionTypes.ts @@ -4,7 +4,7 @@ import { CharacterTypeRaw } from '@/hooks/Mapper/types/character.ts'; import { SolarSystemRawType } from '@/hooks/Mapper/types/system.ts'; import { RoutesList } from '@/hooks/Mapper/types/routes.ts'; import { SolarSystemConnection } from '@/hooks/Mapper/types/connection.ts'; -import { MapOptions, PingData, UserPermissions } from '@/hooks/Mapper/types'; +import { ClientEnv, MapOptions, PingData, UserPermissions } from '@/hooks/Mapper/types'; import { SystemSignature } from '@/hooks/Mapper/types/signatures'; import { RoutesByCategoryType } from '@/hooks/Mapper/mapRootProvider/types.ts'; @@ -32,4 +32,5 @@ export type MapUnionTypes = { mainCharacterEveId: string | null; followingCharacterEveId: string | null; pings: PingData[]; + clientEnv: ClientEnv; }; diff --git a/assets/js/hooks/Mapper/types/options.ts b/assets/js/hooks/Mapper/types/options.ts index 5b6f00172..e31f3cc90 100644 --- a/assets/js/hooks/Mapper/types/options.ts +++ b/assets/js/hooks/Mapper/types/options.ts @@ -11,4 +11,5 @@ export type MapOptions = { show_linked_signature_id_temp_name: StringBoolean; show_temp_system_name: StringBoolean; store_custom_labels: StringBoolean; + intel_source_map_id?: string | null; }; diff --git a/assets/js/hooks/Mapper/types/system.ts b/assets/js/hooks/Mapper/types/system.ts index bc98af626..684154019 100644 --- a/assets/js/hooks/Mapper/types/system.ts +++ b/assets/js/hooks/Mapper/types/system.ts @@ -1,5 +1,4 @@ import { XYPosition } from 'reactflow'; - import { SystemSignature } from './signatures'; export enum SolarSystemStaticInfoRawNames { @@ -117,9 +116,11 @@ export type SolarSystemRawType = { status: number; name: string | null; temporary_name: string | null; + owner_id: string | null; + owner_type: string | null; + custom_flags: string | null; linked_sig_eve_id: string | null; comments_count: number | null; - system_static_info: SolarSystemStaticInfoRaw; system_signatures: SystemSignature[]; }; diff --git a/assets/js/hooks/Mapper/utils/contextStore/types.ts b/assets/js/hooks/Mapper/utils/contextStore/types.ts index 19846e4ca..4c84ac717 100644 --- a/assets/js/hooks/Mapper/utils/contextStore/types.ts +++ b/assets/js/hooks/Mapper/utils/contextStore/types.ts @@ -1,4 +1,4 @@ -export type AnyProperty = T[keyof T]; +export type AnyProperty = T[keyof T] | undefined; export type PCDHandleBeforeUpdate = ( newVal: AnyProperty, diff --git a/assets/js/hooks/ping.ts b/assets/js/hooks/ping.ts index a1b265a21..d91cb8387 100644 --- a/assets/js/hooks/ping.ts +++ b/assets/js/hooks/ping.ts @@ -22,6 +22,10 @@ export default { }, ping(rtt) { this._nowMs = Date.now(); - this.pushEvent('ping', { rtt: rtt }); + try { + this.pushEvent('ping', { rtt: rtt }); + } catch { + // LiveView not connected yet, will retry on reconnect + } }, }; diff --git a/assets/package.json b/assets/package.json index 259c9a24b..b78687424 100644 --- a/assets/package.json +++ b/assets/package.json @@ -15,6 +15,7 @@ "@codemirror/lang-markdown": "^6.3.2", "@codemirror/theme-one-dark": "^6.1.2", "@formkit/auto-animate": "0.7.0", + "@react-icons/all-files": "^4.1.0", "@shopify/draggable": "^1.1.3", "@uiw/react-codemirror": "^4.23.10", "clsx": "^2.1.1", @@ -36,12 +37,14 @@ "react-event-hook": "^3.1.2", "react-flow-renderer": "^10.3.17", "react-hook-form": "^7.53.1", + "react-icons": "^5.5.0", "react-markdown": "^10.0.1", "react-transition-group": "^4.4.5", "react-usestateref": "^1.0.9", "reactflow": "^11.11.4", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "rollup": "^4.34.9", "tailwindcss": "^3.3.6", "quill": "^2.0.3", "turndown": "^7.2.0", @@ -72,6 +75,7 @@ "eslint-plugin-react-refresh": "^0.4.6", "heroicons": "^2.0.18", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "merge-options": "^3.0.4", "postcss": "^8.4.38", "postcss-cli": "^11.0.0", diff --git a/assets/yarn.lock b/assets/yarn.lock index 204205a1b..dbb441010 100644 --- a/assets/yarn.lock +++ b/assets/yarn.lock @@ -1047,6 +1047,11 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b" integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== +"@react-icons/all-files@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@react-icons/all-files/-/all-files-4.1.0.tgz#477284873a0821928224b6fc84c62d2534d6650b" + integrity sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ== + "@reactflow/background@11.3.14": version "11.3.14" resolved "https://registry.yarnpkg.com/@reactflow/background/-/background-11.3.14.tgz#778ca30174f3de77fc321459ab3789e66e71a699" @@ -1286,6 +1291,11 @@ lodash.merge "^4.6.2" postcss-selector-parser "6.0.10" +"@tootallnate/once@2": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.1.tgz#35adc6222e3662fa2222ce123b961476a746b9ea" + integrity sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ== + "@types/babel__core@^7.1.14", "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1594,6 +1604,15 @@ expect "^29.0.0" pretty-format "^29.0.0" +"@types/jsdom@^20.0.0": + version "20.0.1" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.1.tgz#07c14bc19bd2f918c1929541cdaacae894744808" + integrity sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ== + dependencies: + "@types/node" "*" + "@types/tough-cookie" "*" + parse5 "^7.0.0" + "@types/json-schema@^7.0.12": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -1675,6 +1694,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/tough-cookie@*": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" + integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" @@ -1836,16 +1860,48 @@ "@types/babel__core" "^7.20.5" react-refresh "^0.17.0" +abab@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + +acorn-globals@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" + integrity sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q== + dependencies: + acorn "^8.1.0" + acorn-walk "^8.0.2" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== +acorn-walk@^8.0.2: + version "8.3.5" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.5.tgz#8a6b8ca8fc5b34685af15dabb44118663c296496" + integrity sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw== + dependencies: + acorn "^8.11.0" + +acorn@^8.1.0, acorn@^8.11.0, acorn@^8.8.1: + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== + acorn@^8.4.0, acorn@^8.9.0: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -2008,6 +2064,11 @@ async-function@^1.0.0: resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + autoprefixer@^10.4.19: version "10.4.21" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.21.tgz#77189468e7a8ad1d9a37fbc08efc9f480cf0a95d" @@ -2336,6 +2397,13 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" @@ -2396,6 +2464,23 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== + +cssom@~0.3.6: + version "0.3.8" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" + integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== + +cssstyle@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" + integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== + dependencies: + cssom "~0.3.6" + csstype@^3.0.2: version "3.1.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" @@ -2478,6 +2563,15 @@ daisyui@^4.11.1: picocolors "^1" postcss-js "^4" +data-urls@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" + integrity sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ== + dependencies: + abab "^2.0.6" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + data-view-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" @@ -2505,6 +2599,13 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +debug@4: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.4.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" @@ -2512,6 +2613,11 @@ debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3 dependencies: ms "^2.1.3" +decimal.js@^10.4.2: + version "10.6.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" + integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== + decode-named-character-reference@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed" @@ -2552,6 +2658,11 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + dependency-graph@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-1.0.0.tgz#bb5e85aec1310bc13b22dbd76e3196c4ee4c10d2" @@ -2623,6 +2734,13 @@ dom-helpers@^5.0.1: "@babel/runtime" "^7.8.7" csstype "^3.0.2" +domexception@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" + integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw== + dependencies: + webidl-conversions "^7.0.0" + dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" @@ -2657,6 +2775,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +entities@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -2846,6 +2969,17 @@ escape-string-regexp@^5.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== +escodegen@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" + integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionalDependencies: + source-map "~0.6.1" + eslint-config-prettier@^9.1.0: version "9.1.2" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz#90deb4fa0259592df774b600dbd1d2249a78ce91" @@ -2959,7 +3093,7 @@ espree@^9.6.0, espree@^9.6.1: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.4.1" -esprima@^4.0.0: +esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -3160,6 +3294,17 @@ foreground-child@^3.1.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" +form-data@^4.0.0: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + fraction.js@^4.3.7: version "4.3.7" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" @@ -3403,6 +3548,13 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hast-util-to-jsx-runtime@^2.0.0: version "2.3.6" resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz#ff31897aae59f62232e21594eac7ef6b63333e98" @@ -3436,6 +3588,13 @@ heroicons@^2.0.18: resolved "https://registry.yarnpkg.com/heroicons/-/heroicons-2.2.0.tgz#f1f554155152b4ec4d1b7165363d7c583690f77d" integrity sha512-yOwvztmNiBWqR946t+JdgZmyzEmnRMC2nxvHFC90bF1SUttwB6yJKYeme1JeEcBfobdOs827nCyiWBS2z/brog== +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== + dependencies: + whatwg-encoding "^2.0.0" + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -3446,11 +3605,35 @@ html-url-attributes@^3.0.0: resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ignore@^5.2.0, ignore@^5.2.4: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -3685,6 +3868,11 @@ is-plain-obj@^4.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-reference@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.3.tgz#9ef7bf9029c70a67b2152da4adf57c23d718910f" @@ -3955,6 +4143,20 @@ jest-each@^29.7.0: jest-util "^29.7.0" pretty-format "^29.7.0" +jest-environment-jsdom@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz#d206fa3551933c3fd519e5dfdb58a0f5139a837f" + integrity sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/jsdom" "^20.0.0" + "@types/node" "*" + jest-mock "^29.7.0" + jest-util "^29.7.0" + jsdom "^20.0.0" + jest-environment-node@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" @@ -4230,6 +4432,38 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsdom@^20.0.0: + version "20.0.3" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.3.tgz#886a41ba1d4726f67a8858028c99489fed6ad4db" + integrity sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ== + dependencies: + abab "^2.0.6" + acorn "^8.8.1" + acorn-globals "^7.0.0" + cssom "^0.5.0" + cssstyle "^2.3.0" + data-urls "^3.0.2" + decimal.js "^10.4.2" + domexception "^4.0.0" + escodegen "^2.0.0" + form-data "^4.0.0" + html-encoding-sniffer "^3.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.1" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.2" + parse5 "^7.1.1" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.2" + w3c-xmlserializer "^4.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^2.0.0" + whatwg-mimetype "^3.0.0" + whatwg-url "^11.0.0" + ws "^8.11.0" + xml-name-validator "^4.0.0" + jsesc@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" @@ -4924,6 +5158,18 @@ micromatch@^4.0.4, micromatch@^4.0.5, micromatch@^4.0.8: braces "^3.0.3" picomatch "^2.3.1" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.35: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -4984,10 +5230,10 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.11: - version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +nanoid@^3.3.16: + version "3.3.17" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.17.tgz#f1c3aa253c52547956a52c50bff754316f61037a" + integrity sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g== natural-compare@^1.4.0: version "1.4.0" @@ -5031,6 +5277,11 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +nwsapi@^2.2.2: + version "2.2.24" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.24.tgz#f8927043d4c9b516abdebe804a32c8d1f9484d1f" + integrity sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A== + object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -5201,6 +5452,13 @@ parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse5@^7.0.0, parse5@^7.1.1: + version "7.3.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" + integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw== + dependencies: + entities "^6.0.0" + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -5377,11 +5635,11 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== postcss@^8.4.38, postcss@^8.4.47, postcss@^8.5.3: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + version "8.5.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594" + integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg== dependencies: - nanoid "^3.3.11" + nanoid "^3.3.16" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -5461,7 +5719,14 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== -punycode@^2.1.0: +psl@^1.1.33: + version "1.15.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.15.0.tgz#bdace31896f1d97cec6a79e8224898ce93d974c6" + integrity sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w== + dependencies: + punycode "^2.3.1" + +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -5471,6 +5736,11 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -5534,6 +5804,11 @@ react-hook-form@^7.53.1: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.62.0.tgz#2d81e13c2c6b6d636548e440818341ca753218d0" integrity sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA== +react-icons@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.5.0.tgz#8aa25d3543ff84231685d3331164c00299cdfaf2" + integrity sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw== + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -5706,6 +5981,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -5832,6 +6112,11 @@ safe-regex-test@^1.1.0: es-errors "^1.3.0" is-regex "^1.2.1" +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + sass-loader@^14.2.1: version "14.2.1" resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-14.2.1.tgz#db9ad96b56dc1c1ea546101e76375d5b008fec70" @@ -5850,6 +6135,13 @@ sass@^1.77.2: optionalDependencies: "@parcel/watcher" "^2.4.1" +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + scheduler@^0.23.2: version "0.23.2" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" @@ -5988,7 +6280,7 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.6.0, source-map@^0.6.1: +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -6031,7 +6323,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6116,7 +6417,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6196,6 +6504,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + synckit@^0.11.7: version "0.11.11" resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.11.tgz#c0b619cf258a97faa209155d9cd1699b5c998cb0" @@ -6289,6 +6602,23 @@ topbar@^3.0.0: resolved "https://registry.yarnpkg.com/topbar/-/topbar-3.0.0.tgz#7353f10852778f6352feac2f92015b673b5e96eb" integrity sha512-mhczD7KfYi1anfoMPKRdl0wPSWiYc0YOK4KyycYs3EaNT15pVVNDG5CtfgZcEBWIPJEdfR7r8K4hTXDD2ECBVQ== +tough-cookie@^4.1.2: + version "4.1.4" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" + integrity sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + +tr46@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" + integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA== + dependencies: + punycode "^2.1.1" + trim-lines@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" @@ -6479,6 +6809,11 @@ unist-util-visit@^5.0.0: unist-util-is "^6.0.0" unist-util-visit-parents "^6.0.0" +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + universalify@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" @@ -6499,6 +6834,14 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + use-local-storage-state@^19.3.1: version "19.5.0" resolved "https://registry.yarnpkg.com/use-local-storage-state/-/use-local-storage-state-19.5.0.tgz#25bf46dd45b491020c03db516b46a4ff93b3a923" @@ -6576,6 +6919,13 @@ w3c-keyname@^2.2.4: resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== +w3c-xmlserializer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" + integrity sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw== + dependencies: + xml-name-validator "^4.0.0" + walker@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" @@ -6583,6 +6933,31 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + +whatwg-url@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018" + integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ== + dependencies: + tr46 "^3.0.0" + webidl-conversions "^7.0.0" + which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" @@ -6653,7 +7028,16 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -6684,6 +7068,21 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" +ws@^8.11.0: + version "8.21.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc" + integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw== + +xml-name-validator@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" + integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" diff --git a/config/config.exs b/config/config.exs index 77c02bb5d..d0fda3f61 100644 --- a/config/config.exs +++ b/config/config.exs @@ -87,9 +87,30 @@ config :ash_pagify, table: [opts: {WandererAppWeb.CoreComponents, :table_opts}] # Configures Elixir's Logger +# +# Logger drops any metadata key not listed here. The character/map tracking code +# annotates its error logs with character_id, map_id, tracking_pool, endpoint and +# friends; without them in this list those annotations were silently discarded, +# which is why a previous round of "add logging to troubleshoot tracking" showed +# nothing useful in production. config :logger, :console, format: "$time $metadata[$level] $message\n", - metadata: [:application, :module, :function, :line, :request_id] + metadata: [ + :application, + :module, + :function, + :line, + :request_id, + :character_id, + :map_id, + :user_id, + :system_id, + :connection_id, + :tracking_pool, + :error_type, + :endpoint, + :reason + ] # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason @@ -145,6 +166,22 @@ config :git_ops, manage_readme_version: "README.md", version_tag_prefix: "v" +# Add this to the existing configuration +config :wanderer_app, :signature_cleanup, + # Default to 24 hours + max_age_hours: 24 + +# Signature expiration defaults. The env-var overrides live in runtime.exs โ€” +# reading System.get_env/1 here would bake the build machine's values into the +# release instead of the deployment's. +config :wanderer_app, :signatures, + # Wormhole signatures expire after the configured hours (0 means never expire) + wormhole_expiration_hours: 24, + # All other signatures expire after the configured hours (0 means never expire) + default_expiration_hours: 72, + # Don't expire signatures that have connections + preserve_connected: true + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index 7b18a442a..ffd4b9c0e 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -4,7 +4,8 @@ import Config config :wanderer_app, WandererApp.Repo, username: "postgres", password: "postgres", - hostname: "localhost", + hostname: System.get_env("DB_HOST", "localhost"), + port: 5432, database: "wanderer_dev", stacktrace: true, show_sensitive_data_on_connection_error: true, diff --git a/config/runtime.exs b/config/runtime.exs index adccbf30c..5bff5b86c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -15,11 +15,7 @@ config_dir = System.get_env("CONFIG_DIR", "/run/secrets") app_name = System.get_env("FLY_APP_NAME", "NOT_FLY_APP") -host = - case app_name == "NOT_FLY_APP" do - true -> System.get_env("PHX_HOST", "localhost") - _ -> "#{app_name}.fly.dev" - end +host = resolve_host(System.get_env("PHX_HOST"), app_name) web_port = System.get_env( @@ -32,10 +28,7 @@ web_port = |> String.to_integer() web_app_url = - case app_name == "NOT_FLY_APP" do - true -> System.get_env("WEB_APP_URL", "http://#{host}:#{web_port}") - _ -> "https://#{host}" - end + resolve_web_app_url(System.get_env("WEB_APP_URL"), host, web_port, app_name) base_url = URI.parse(web_app_url) @@ -73,11 +66,23 @@ wanderer_kills_base_url = config_dir |> get_var_from_path_or_env("WANDERER_KILLS_BASE_URL", "ws://wanderer-kills:4004") +# Fly's 6PN `.internal` and `.flycast` addresses are IPv6-only, and gen_tcp +# resolves hostnames as IPv4 by default. Mirrors the ECTO_IPV6 precedent below. +wanderer_kills_ipv6 = + config_dir + |> get_var_from_path_or_env("WANDERER_KILLS_IPV6", "false") + |> String.to_existing_atom() + map_subscriptions_enabled = config_dir |> get_var_from_path_or_env("WANDERER_MAP_SUBSCRIPTIONS_ENABLED", "false") |> String.to_existing_atom() +intel_sharing_enabled = + config_dir + |> get_var_from_path_or_env("WANDERER_INTEL_SHARING_ENABLED", "false") + |> String.to_existing_atom() + map_subscription_characters_limit = config_dir |> get_int_from_path_or_env("WANDERER_MAP_SUBSCRIPTION_CHARACTERS_LIMIT", 10_000) @@ -180,11 +185,13 @@ config :wanderer_app, character_api_disabled: character_api_disabled, wanderer_kills_service_enabled: wanderer_kills_service_enabled, wanderer_kills_base_url: wanderer_kills_base_url, + wanderer_kills_ipv6: wanderer_kills_ipv6, map_subscriptions_enabled: map_subscriptions_enabled, map_connection_auto_expire_hours: map_connection_auto_expire_hours, map_connection_auto_eol_hours: map_connection_auto_eol_hours, map_connection_eol_expire_timeout_mins: map_connection_eol_expire_timeout_mins, wallet_tracking_enabled: wallet_tracking_enabled, + intel_sharing_enabled: intel_sharing_enabled, restrict_maps_creation: restrict_maps_creation, restrict_acls_creation: restrict_acls_creation, subscription_settings: %{ @@ -238,6 +245,10 @@ config :wanderer_app, System.get_env("WANDERER_LOCATION_CONCURRENCY", "#{System.schedulers_online() * 12}") |> String.to_integer() +# Discord kill notification delivery pool - read by discord_pool_size/0 in application.ex +config :wanderer_app, :discord_finch, + pool_size: System.get_env("WANDERER_DISCORD_POOL_SIZE", "10") |> String.to_integer() + config :ueberauth, Ueberauth, providers: [ eve: @@ -443,15 +454,50 @@ if config_env() == :prod do |> get_var_from_path_or_env("PROMEX_DISABLED", "true") |> String.to_existing_atom() + metrics_port = System.get_env("METRICS_PORT", "4021") |> String.to_integer() + + # On Fly the scrape port is pinned in fly.toml's [[metrics]] block, which + # cannot read this env var. A mismatch is silent in the worst way: PromEx + # serves /metrics correctly on the port you chose, the scraper polls 4021 and + # gets nothing, and the result reads as "the app emits no metrics" rather than + # as a misconfiguration. Fail the boot instead โ€” same reasoning as the + # WEB_APP_URL scheme check above. Self-hosted deployments are unaffected: + # off Fly, any port is fine because whatever scrapes it is configured by hand. + if app_name != "NOT_FLY_APP" and not promex_disabled? and metrics_port != 4021 do + raise """ + METRICS_PORT is #{metrics_port}, but fly.toml's [[metrics]] block scrapes 4021. + + Fly would silently collect nothing. Either set METRICS_PORT=4021 (or unset + it) or change the port in fly.toml's [[metrics]] block to match. + """ + end + + # Fly scrapes [[metrics]] over the 6PN private network, which is IPv6-only + # (same reason WANDERER_KILLS_IPV6 exists). Bound to IPv4 there, the endpoint + # answers a local curl fine and the scrape still returns nothing, which reads + # as "no metrics" rather than as a connection error. + # + # Off Fly, stay on IPv4: binding :: on a host booted with ipv6.disable=1 + # fails with :eafnosupport, and that takes the whole app down rather than just + # the metrics server. Self-hosters configure their own scraper against + # whatever this binds, so IPv4 costs them nothing. + # + # Neither address is publicly routable on Fly โ€” this port has no + # [http_service] or [[services]] entry, so the proxy never forwards to it. + metrics_bind_ip = + if app_name != "NOT_FLY_APP", + do: {0, 0, 0, 0, 0, 0, 0, 0}, + else: {0, 0, 0, 0} + config :wanderer_app, WandererApp.PromEx, disabled: promex_disabled?, manual_metrics_start_delay: :no_delay, metrics_server: [ - port: System.get_env("METRICS_PORT", "4021") |> String.to_integer(), + port: metrics_port, path: "/metrics", protocol: :http, pool_size: 5, - cowboy_opts: [ip: {0, 0, 0, 0}] + cowboy_opts: [ip: metrics_bind_ip] ] end @@ -481,4 +527,71 @@ config :wanderer_app, :external_events, config_dir |> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "false") |> String.to_existing_atom(), - webhook_timeout_ms: config_dir |> get_int_from_path_or_env("WANDERER_WEBHOOK_TIMEOUT_MS", 15000) + webhook_timeout_ms: + config_dir |> get_int_from_path_or_env("WANDERER_WEBHOOK_TIMEOUT_MS", 15000), + discord_max_killmail_age_seconds: + config_dir + |> get_int_from_path_or_env("WANDERER_DISCORD_MAX_KILLMAIL_AGE_SECONDS", 3600), + discord_startup_grace_seconds: + config_dir + |> get_int_from_path_or_env("WANDERER_DISCORD_STARTUP_GRACE_SECONDS", 600), + discord_startup_max_killmail_age_seconds: + config_dir + |> get_int_from_path_or_env("WANDERER_DISCORD_STARTUP_MAX_KILLMAIL_AGE_SECONDS", 120), + notable_items_enabled: + config_dir + |> get_var_from_path_or_env("WANDERER_NOTABLE_ITEMS_ENABLED", "false") + |> String.to_existing_atom(), + notable_items_threshold_isk: + config_dir |> get_int_from_path_or_env("WANDERER_NOTABLE_ITEMS_THRESHOLD_ISK", 50_000_000), + notable_items_limit: config_dir |> get_int_from_path_or_env("WANDERER_NOTABLE_ITEMS_LIMIT", 5), + notable_items_timeout_ms: + config_dir |> get_int_from_path_or_env("WANDERER_NOTABLE_ITEMS_TIMEOUT_MS", 1500), + corp_tickers_enabled: + config_dir + |> get_var_from_path_or_env("WANDERER_CORP_TICKERS_ENABLED", "true") + |> String.to_existing_atom(), + corp_tickers_timeout_ms: + config_dir |> get_int_from_path_or_env("WANDERER_CORP_TICKERS_TIMEOUT_MS", 1500), + discord_mentions_enabled: + config_dir + |> get_var_from_path_or_env("WANDERER_DISCORD_MENTIONS_ENABLED", "true") + |> String.to_existing_atom(), + discord_bot_token: + if(config_env() != :test, + do: config_dir |> get_var_from_path_or_env("DISCORD_BOT_TOKEN") + ), + discord_guild_id: + if(config_env() != :test, + do: config_dir |> get_var_from_path_or_env("DISCORD_GUILD_ID") + ) + +# Nostrum powers voice-participant mentions on Discord kill notifications. +# Configured only when a bot token exists; VoiceGateway decides at boot +# whether to actually start it. Never configured in test โ€” the suite must +# stay hermetic. +if config_env() != :test do + # Trimmed, blank treated as unset โ€” mirrors Env.discord_bot_token/0, so a + # stray DISCORD_BOT_TOKEN="" never hands Nostrum an empty token. + raw_discord_bot_token = config_dir |> get_var_from_path_or_env("DISCORD_BOT_TOKEN") + + discord_bot_token = + if raw_discord_bot_token && String.trim(raw_discord_bot_token) != "", + do: String.trim(raw_discord_bot_token) + + if discord_bot_token do + config :nostrum, + token: discord_bot_token, + gateway_intents: [:guilds, :guild_voice_states], + ffmpeg: false + end +end + +# Signature expiration โ€” evaluated at boot so the deployment's env vars win over +# whatever was set when the release was built. Defaults mirror config.exs. +config :wanderer_app, :signatures, + wormhole_expiration_hours: + config_dir |> get_int_from_path_or_env("SIGNATURE_WORMHOLE_EXPIRATION_HOURS", 24), + default_expiration_hours: + config_dir |> get_int_from_path_or_env("SIGNATURE_DEFAULT_EXPIRATION_HOURS", 72), + preserve_connected: true diff --git a/config/test.exs b/config/test.exs index 728886f4a..698a7794e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -31,8 +31,21 @@ config :wanderer_app, environment: :test, map_subscriptions_enabled: false, wanderer_kills_service_enabled: false, + websocket_client_module: Test.WebSocketClientMock, sse: [enabled: false], - external_events: [webhooks_enabled: false] + external_events: [ + webhooks_enabled: false, + # `0` disables the startup window, which the non-negative validator honours. + # Without this, every test calling `start_supervised!(DiscordDispatcher)` + # (discord_dispatcher_test.exs:110) would begin inside a live 600-second + # grace period, and the existing age assertions in + # discord_killmail_age_test.exs would quietly start measuring against 120 + # seconds instead of 3600. Tests that exercise the window set it explicitly. + discord_startup_grace_seconds: 0 + ], + discord_http_client: WandererApp.ExternalEvents.Discord.HttpStub, + # No test may reach real ESI. Enrichment tests override this per test. + esi_client: WandererApp.Esi.OfflineStub # We don't run a server during test. If one is required, # you can enable the server option below. diff --git a/docs/ZOO-FORK.md b/docs/ZOO-FORK.md new file mode 100644 index 000000000..14e28468f --- /dev/null +++ b/docs/ZOO-FORK.md @@ -0,0 +1,771 @@ +# Zoo Fork Documentation + +**Branch:** `guarzo/zoo` +**Upstream baseline:** `wanderer-industries/wanderer` โ€” `main` @ `b7ddbc486`, `develop` @ `35ea4e5f1` +**Divergence:** 58 commits / 312 files / ~43.5k insertions ahead; 0 commits behind either upstream branch +**Last Updated:** 2026-08-08 + +This document describes the zoo fork's extensions to upstream Wanderer: database schema +changes, backend subsystems, frontend themes, deployment, and which changes are candidates +for upstream contribution. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Database Schema Extensions](#database-schema-extensions) +3. [Discord Notifications](#discord-notifications) +4. [Intel Sharing](#intel-sharing) +5. [Theme System](#theme-system) +6. [Label System](#label-system) +7. [Signature Cleanup](#signature-cleanup) +8. [Fleet Readiness](#fleet-readiness) +9. [Deployment (Fly.io)](#deployment-flyio) +10. [CI](#ci) +11. [Configuration Reference](#configuration-reference) +12. [Commit Convention](#commit-convention) +13. [Upstream PR Recommendations](#upstream-pr-recommendations) +14. [Key Files Reference](#key-files-reference) +15. [Maintenance Notes](#maintenance-notes) + +--- + +## Overview + +| Feature | Purpose | Upstream candidate? | +|---------|---------|---------------------| +| Discord kill notifications | Per-map killmail embeds to Discord webhooks | Yes โ€” large, needs discussion | +| Discord route alerts | Alert when a kill lands within N jumps of home | With the above | +| Discord voice mentions | Mention users in the map's voice channel | With the above | +| Intel sharing between maps | Copy system intel from a source map to subscribers | Yes โ€” Tier 2 | +| Zoo theme | Custom visual styling for wormhole mapping | No | +| Label semantics | EVE-specific label meanings (EOL, Crit, etc.) | No | +| System ownership | Track corp/alliance ownership of systems | No | +| Fleet readiness | Mark characters ready for fleet operations | Tier 2 (generalize first) | +| On-demand signature cleanup | Configurable automatic signature expiration | Yes | +| Connection loop type | Self-connecting wormhole support | No | +| Fly.io deployment | Config, IPv6 transports, `/health`, gated deploy | Partly โ€” see Tier 1 | +| Parallel CI | 4-way partitioned test suite, fixed caches | Yes | +| Upstream bug fixes | See [Tier 1](#tier-1-strongly-recommend) | Yes | + +--- + +## Database Schema Extensions + +The fork adds 2 tables and 8 columns to upstream tables, across 15 migration files (13 new, +2 modified in place). A further 4 columns are added by later migrations to the fork's *own* +new tables โ€” the three route-alert columns and `mention_targets` below โ€” which is why the +tables above list more columns than the "8" here counts. + +### New tables + +#### `map_discord_notifications_v1` + +Per-map Discord notification config. One row per map (unique index on `map_id`). + +| Column | Type | Purpose | +|--------|------|---------| +| `enabled?` | boolean | Master switch for the map | +| `wh_only` | boolean | Restrict notifications to wormhole systems | +| `excluded_systems` | bigint[] | Solar system IDs to never notify on | +| `route_alerts_enabled?` | boolean | Enable proximity route alerts | +| `home_system_id` | bigint | Origin for route-alert jump distance | +| `route_max_jumps` | bigint | Alert threshold in jumps (default 5) | +| `last_delivery_at` / `last_error` / `last_error_at` / `consecutive_failures` | โ€” | Delivery health | + +`encrypted_webhook_url` existed on this table originally and was moved to the child table +by `20260803210357_split_discord_webhooks`. + +#### `map_discord_webhooks_v1` + +One webhook destination per `(notification_id, role)`. Roles split delivery so kills, +route alerts, and system events can target different channels. + +| Column | Type | Purpose | +|--------|------|---------| +| `role` | text | Destination role (e.g. `system`) | +| `encrypted_webhook_url` | binary | AshCloak-encrypted webhook URL | +| `enabled?` | boolean | Per-destination switch | +| `mention_targets` | text[] | Roles/users to mention on delivery (`20260807203453`) | +| `last_delivery_at` / `last_error` / `last_error_at` / `consecutive_failures` | โ€” | Delivery health | + +> `20260803210357_split_discord_webhooks.exs` is **hand-edited** after generation: it copies +> existing rows into the new table rather than dropping them, and strips four unrelated +> resources the generator picked up from stale snapshots. Read its `@moduledoc` before +> touching it โ€” the ciphertext-copy reasoning and the `enabled?` semantics are load-bearing. + +### Added columns + +#### `map_system_v1` + +| Column | Type | Purpose | Migration | +|--------|------|---------|-----------| +| `custom_flags` | text | Arbitrary flags for zoo features | `20250122214138` | +| `owner_id` | text | Corporation or Alliance EVE ID | `20250204223853` | +| `owner_type` | text | Entity type: 'corp' or 'alliance' | `20250204223853` | +| `owner_ticker` | text | Display ticker [TICKER] | `20250307165740` | + +#### `map_user_settings_v1` + +| Column | Type | Purpose | Migration | +|--------|------|---------|-----------| +| `ready_characters` | text[] | Character EVE IDs marked as fleet-ready | `20250625024813` | + +#### `maps_v1` + +| Column | Type | Purpose | Migration | +|--------|------|---------|-----------| +| `intel_source_map_id` | uuid FK (self, `nilify_all`) | Map that provides intel to this one | `20260209100000` | + +#### `map_system_comments_v1`, `map_system_structures_v1` + +| Column | Type | Purpose | Migration | +|--------|------|---------|-----------| +| `inherited_from_map_id` | uuid | Marks rows copied in by intel sync | `20260209100001` | + +### Migration files + +```text +priv/repo/migrations/ +โ”œโ”€โ”€ 20250122214138_add_zoo_flags.exs +โ”œโ”€โ”€ 20250204223853_add_system_owners.exs +โ”œโ”€โ”€ 20250307165740_add_owner_ticker.exs +โ”œโ”€โ”€ 20250625024813_add_fleet_readiness_ready_characters.exs +โ”œโ”€โ”€ 20260209100000_add_intel_source_map_id.exs +โ”œโ”€โ”€ 20260209100001_add_inherited_from_map_id.exs +โ”œโ”€โ”€ 20260801200000_fix_maps_scopes_default.exs # see warning below +โ”œโ”€โ”€ 20260801234058_add_map_discord_notifications.exs +โ”œโ”€โ”€ 20260803202833_create_map_discord_webhooks.exs +โ”œโ”€โ”€ 20260803210357_split_discord_webhooks.exs # hand-edited +โ”œโ”€โ”€ 20260804180000_add_map_chain_locked_by_fkey.exs # constraint only, no column +โ”œโ”€โ”€ 20260807203452_add_route_alert_config.exs +โ””โ”€โ”€ 20260807203453_add_webhook_mention_targets.exs +``` + +`20260804180000_add_map_chain_locked_by_fkey.exs` adds **no column**. It creates the +`map_chain_v1_locked_by_id_fkey` constraint on the existing `locked_by_id` reference, which +`MapConnection`'s `belongs_to :locked_by` has always implied but no database has ever had. + +Two upstream migrations are also **modified in place** โ€” `20260331192521` and `20260406213852` +each change `default: '{wormholes}'` to `default: ~c"{wormholes}"` to silence the Elixir +single-quoted-charlist deprecation. The emitted DDL is identical, so this is cosmetic, but it +means those two files can conflict whenever upstream changes them, until the equivalent change +lands upstream. + +> **โš  `20260801200000_fix_maps_scopes_default.exs` is not an upstream migration.** +> Commit `1b4970ffb` (#122) describes it as "upstream's", but it exists on neither +> `upstream/main` nor `upstream/develop` โ€” it came in via #118, which pulled from an +> unmerged upstream PR. The fork's own equivalent (`20260804190000`, from #102) was deleted +> to resolve an `Ecto.MigrationError: migration name fix_maps_scopes_default is duplicated` +> that aborted the entire migrator. **If upstream later merges that fix under a different +> timestamp, the duplicate-module-name collision returns.** Check for a +> `FixMapsScopesDefault` module on every upstream merge. + +### Rollback SQL (if needed) + +```sql +ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS custom_flags; +ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_id; +ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_type; +ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_ticker; + +ALTER TABLE map_user_settings_v1 DROP COLUMN IF EXISTS ready_characters; + +ALTER TABLE maps_v1 DROP COLUMN IF EXISTS intel_source_map_id; +ALTER TABLE map_system_comments_v1 DROP COLUMN IF EXISTS inherited_from_map_id; +ALTER TABLE map_system_structures_v1 DROP COLUMN IF EXISTS inherited_from_map_id; + +DROP TABLE IF EXISTS map_discord_webhooks_v1; +DROP TABLE IF EXISTS map_discord_notifications_v1; + +ALTER TABLE map_chain_v1 DROP CONSTRAINT IF EXISTS map_chain_v1_locked_by_id_fkey; +``` + +--- + +## Discord Notifications + +The largest fork subsystem (~4,600 lines under `lib/wanderer_app/external_events/discord/` +plus `discord_dispatcher.ex`). It sits alongside upstream's `webhook_dispatcher.ex` and +consumes the same external-events stream. + +### Modules + +| Module | Purpose | +|--------|---------| +| `discord_dispatcher.ex` | Entry point; owns delivery decisions and per-map state | +| `discord/worker.ex`, `worker_supervisor.ex` | Per-map delivery workers | +| `discord/router.ex` | Routes an event to the right webhook role | +| `discord/matcher.ex` | Decides whether a killmail concerns a given map | +| `discord/embed_formatter.ex` | Builds the Discord embed | +| `discord/notable_items.ex` | Enriches embeds with high-value dropped items (ESI, concurrent) | +| `discord/corp_tickers.ex` | Resolves missing corporation tickers from ESI | +| `discord/mentions.ex` | Mention budget and formatting | +| `discord/guild.ex` | Reads a guild's roles and members for the mention pickers | +| `discord/voice_gateway.ex`, `voice_participants.ex` | Nostrum gateway; mentions users in voice | +| `discord/route_watcher.ex`, `route_watcher_supervisor.ex` | Proximity route alerts | +| `discord/system_name.ex` | System name resolution for embeds | +| `discord/http_client.ex` | Webhook HTTP transport (dedicated Finch pool) | + +### Voice mentions and Nostrum + +`nostrum` is declared `runtime: false` in `mix.exs` and listed as `nostrum: :load` in the +release, so the code ships but does not auto-start. `VoiceGateway` starts it at boot **only** +when a bot token and guild ID are configured. `config/runtime.exs` never configures Nostrum +in `:test` โ€” the suite must stay hermetic. + +### Route alerts + +`lib/wanderer_app/map/route_alert/evaluator.ex` computes jump distance from the map's +configured `home_system_id`; `route_watcher.ex` drives the per-map watch loop and posts to +the route-alert webhook role when a kill lands within `route_max_jumps`. + +### Kill filter semantics + +The Notifications settings tab keeps its help text to one or two sentences per control. The +full routing rules, which are too long to render next to an input, are here. + +**Character channel.** Receives kills involving characters tracked on this map, wherever +they happen. With no character webhook configured, those kills go to the system channel +instead. + +**Corporation filter** (`map_discord_notifications_v1.focus_corp_ids`). When empty, the +character channel is driven by this map's tracked characters. When set, it replaces that +membership test entirely: only kills involving one of the listed corporations are routed to +the character channel, and the map's tracked characters no longer select kills on their own. +Kills selected by a corporation match bypass both the excluded-system list and the +wormhole-only flag โ€” the point of the filter is to follow a corporation everywhere, so a +space-based exclusion would silently defeat it. + +**Excluded systems** and **wormhole-only** apply to the system channel, and to the character +channel only when no corporation filter is set. + +None of these apply to route alerts, which have their own channel and their own trigger. + +--- + +## Intel Sharing + +`lib/wanderer_app/map/intel_sync.ex` copies intel from a source map to a subscriber map for +a given solar system, either on visibility or on a manual re-sync. + +- **Wiring:** `maps_v1.intel_source_map_id` designates the source map. +- **Fields copied:** `custom_name`, `description`, `tag`, `temporary_name`, `labels`, `status`. +- **Also copied:** system comments and structures, tagged with `inherited_from_map_id`. +- **Flag:** `WANDERER_INTEL_SHARING_ENABLED` (default `false`). When off, `sync_system/3` + returns `{:ok, :disabled}` without touching the database. + +--- + +## Theme System + +Zoo adds a `zoo` theme alongside `default`, `pathfinder`, and the accessible themes. + +### Key Files + +| File | Purpose | +|------|---------| +| `assets/js/hooks/Mapper/components/map/styles/zoo-theme.scss` | Zoo theme styles | +| `assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeZoo.tsx` | Zoo node component | +| `assets/js/hooks/Mapper/components/map/components/SolarSystemNode/SolarSystemNodeZoo.module.scss` | Zoo node styles | +| `assets/js/hooks/Mapper/components/map/labelIconMap.tsx` | Label icons and mappings | + +### Theme Characteristics + +- **Node Style:** Custom node component with zoo-specific rendering +- **Connection Mode:** Strict (vs Loose for other themes) +- **Labels:** EVE wormhole-specific meanings (see Label System below) +- **Colors:** Custom color palette for wormhole states + +### CSS Class Namespace + +Zoo-specific CSS classes use the `eve-zoo-` prefix: + +```scss +.eve-zoo-effect-color-has-eol { fill: #FF69B4; } +.eve-zoo-effect-color-has-gas { fill: #FFFDD0; } +.eve-zoo-effect-color-is-critical { fill: #8B0000; } +.eve-zoo-effect-color-is-dead-end { fill: #34495E; } +``` + +--- + +## Label System + +The zoo fork repurposes upstream's generic labels (A/B/C/1/2/3) with EVE Online +wormhole-specific meanings. + +### Label Mappings + +`Key` is the value actually written to `system.labels`. `Enum` is the TypeScript member name +in `LABELS`, which never leaves the frontend. `Badge` is the `shortName` rendered on the node. + +| Key | Enum | Upstream | Zoo Meaning | Badge | Icon (`react-icons`) | Use Case | +|-----|------|----------|-------------|-------|----------------------|----------| +| `de` | `la` | Label A | Dead End | `DE` | `MdOutlineBlock` | System with no exit wormholes | +| `gas` | `lb` | Label B | Gas Site | `GAS` | `FaIndustry` | System has harvestable gas sites | +| `eol` | `lc` | Label C | End of Life | `EOL` | `FaHourglassEnd` | Wormhole about to collapse (<4h) | +| `crit` | `l1` | Label 1 | Critical Mass | `CRIT` | `FaExclamationTriangle` | Wormhole at mass verge | +| `structure` | `l2` | Label 2 | Structure | `LP` | `MdLocalFireDepartment` | System has attackable structure | +| `steve` | `l3` | Label 3 | Steve/Danger | `DB` | `FaSkull` | High danger (historic name) | + +The `LP` and `DB` badges are inherited oddities, not typos. `LP` is "low power" โ€” the +`structure` label is commented as "Low Power Structure" in `labelIconMap.tsx`. `DB` has no +expansion anywhere in the source; treat it as historic. The `name` field in `LABELS_INFO` +("Structure", "Steve") is what the context menu shows; `shortName` is what the node badge +shows. + +### Storage + +Labels are stored as the **zoo keys** โ€” `de`, `gas`, `eol`, `crit`, `structure`, `steve` โ€” +not the upstream `la`/`lb`/`lc` keys. `LABELS` is a TypeScript enum whose *member names* are +`la`โ€ฆ`l3` but whose *values* are the zoo keys, and it is the value that +`LabelsManager.toggleLabel/1` stores and `LabelsManager.toString/0` serializes into the JSON +written to `system.labels`. Querying the database for `la` will not match anything. + +The backend treats `labels` as an opaque string; the frontend is the only producer and +consumer of these keys. + +### Files + +- **Definition:** `assets/js/hooks/Mapper/components/map/labelIconMap.tsx` โ€” `LABELS`, + `LABELS_INFO`, `LABELS_ORDER`, `LABEL_ICON_MAP` +- **Zoo styles:** `assets/js/hooks/Mapper/components/map/zooConstants.ts` โ€” + `ZOO_BOOKMARK_STYLES` / `ZOO_TEXT_STYLES`, spread into `MARKER_BOOKMARK_BG_STYLES` by + `constants.ts`. Note these cover `de`, `gas`, `eol` and `crit` only; `structure` and + `steve` fall through to the upstream `wd-marker-bookmark-color-l2`/`-l3` classes. +- **Re-export:** `assets/js/hooks/Mapper/components/map/constants.ts` โ€” merges the above +- **CSS:** `assets/js/hooks/Mapper/components/map/styles/zoo-theme.scss` +- **Serialization:** `assets/js/hooks/Mapper/utils/labelsManager.ts` +- **Render:** `SolarSystemNodeZoo.tsx` (node badges), `useLabelsMenu.ts` (context menu) + +--- + +## Signature Cleanup + +Zoo implements on-demand signature cleanup (`lib/wanderer_app/map/signature_cleanup.ex`, +driven from `map_signatures_event_handler.ex`) in addition to upstream's daily batch cleanup. + +### Comparison + +| Aspect | Upstream GarbageCollector | Zoo On-Demand Cleanup | +|--------|---------------------------|----------------------| +| **Location** | `map_garbage_collector.ex` | `signature_cleanup.ex` | +| **Trigger** | Daily via Quantum scheduler | When user views/updates signatures | +| **Scope** | All signatures globally | Per-system | +| **Wormhole Expiration** | 14 days (hardcoded) | 24 hours (configurable) | +| **Other Signatures** | 14 days (hardcoded) | 72 hours (configurable) | +| **Preserve Connected** | No | Yes (configurable) | + +### How They Interact + +1. Zoo cleanup runs first (on user interaction) with aggressive thresholds +2. Upstream cleanup runs daily as a safety net +3. No conflict: zoo deletes before upstream sees the signatures +4. Upstream catches signatures in never-accessed systems + +The fork also hardened the upstream collector itself โ€” see +[Tier 1](#tier-1-strongly-recommend). + +--- + +## Fleet Readiness + +Allows users to mark characters as "ready for fleet" operations. + +### Features + +- Mark/unmark characters as fleet-ready +- View list of ready characters with locations and ships +- Per-map user settings storage + +### Implementation + +| Component | Location | +|-----------|----------| +| UI Components | `assets/js/hooks/Mapper/components/mapRootContent/components/FleetReadiness/` | +| Event Handler | `lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex` | +| Ash Resource | `lib/wanderer_app/api/map_user_settings.ex` (update_ready_characters action) | +| Repository | `lib/wanderer_app/repositories/map_user_settings_repo.ex` | + +> **No feature flag.** Fleet readiness is unconditionally on. A +> `WANDERER_FLEET_READINESS_ENABLED` variable was parsed in `config/runtime.exs` and stored +> under `:wanderer_app, :fleet_readiness_enabled`, but no `Env` accessor or call site was ever +> written for it in any branch, so setting it had no effect. It was removed rather than wired +> up: gating it at the default of `false` would have switched the feature off for every +> existing deployment. + +--- + +## Deployment (Fly.io) + +The fork deploys to Fly.io; upstream's VM release pipeline was dropped (`ce58765b9`). + +### Fly-specific behaviour + +| Concern | Handling | +|---------|----------| +| Hostname | `ConfigHelpers.resolve_host/2` โ€” `PHX_HOST` wins, `FLY_APP_NAME` is the fallback | +| Base URL | `ConfigHelpers.resolve_web_app_url/4` โ€” `WEB_APP_URL` wins, https assumed on Fly | +| Route builder over 6PN | `RouteBuilderClient.connect_opts/0` sets `inet6: true` (IPv4 fallback retained) | +| Kills websocket over 6PN | `WANDERER_KILLS_IPV6` (default `false`), mirrors the `ECTO_IPV6` precedent | +| Health check | `GET /health` โ†’ `HealthController` | + +> The `/health` route's **position in `router.ex` is load-bearing**: it must stay above +> `live "/:slug", MapLive, :index`, or the wildcard swallows it and returns a 302 to +> `/welcome`. Its pipeline is deliberately minimal โ€” no `CheckApiDisabled`, no auth, no rate +> limiting โ€” because Fly kills a machine that fails its health check and there is one machine. + +### Automatic deploy + +`.github/workflows/zoo-deploy.yml` deploys `guarzo/zoo` to Fly automatically: a push whose +test suite goes green deploys with no human in the loop. The `production-deploy` environment +still exists and the workflow still references it, but it carries **no approval rule** โ€” the +rule was removed once CI was trusted enough to be the only gate. + +> Do not remove `environment: production-deploy` from the workflow to "clean up" the now-empty +> environment. `FLY_DEPLOY_TOKEN` is an **environment** secret, not a repository secret, so +> dropping that line makes it resolve to the empty string and every deploy fails at +> `flyctl deploy` with an auth error. The scoping is also what keeps the production credential +> unreadable from `advanced-test.yml`. + +The Fly credential is read from `FLY_DEPLOY_TOKEN` (not `FLY_API_TOKEN`), and the release +number is read from `.Version`. Design and plan documents for the original approval-gated +version โ€” `docs/superpowers/specs/2026-08-07-deploy-approval-gate-design.md` and +`docs/superpowers/plans/2026-08-07-deploy-approval-gate.md` โ€” are retained as history; they +describe the approval flow, which no longer applies. + +--- + +## CI + +`.github/workflows/test.yml` was restructured (`e815c2283`, #121) from upstream's single +monolithic job into: + +- `setup` โ€” one dependency install/compile, cached for the rest +- `tests` โ€” 4-way `mix test --partitions` matrix (the `PARTITIONS` env var must match the + `partition` matrix list; `config/test.exs` already suffixes the database name with + `MIX_TEST_PARTITION`, which is upstream's default) +- `static` โ€” format, warning count, Credo +- `dialyzer` โ€” PLT build + run +- `coverage` โ€” coverage report and PR comment + +The workflows also run on `guarzo/zoo` pull requests (`2e6487a84`, #117). + +--- + +## Configuration Reference + +### Compile-time (`config/config.exs`) + +```elixir +config :wanderer_app, :signatures, + wormhole_expiration_hours: 24, + default_expiration_hours: 72, + preserve_connected: true + +config :wanderer_app, :signature_cleanup, max_age_hours: 24 +``` + +### Runtime environment variables (`config/runtime.exs`) + +| Variable | Default | Purpose | +|----------|---------|---------| +| `WANDERER_INTEL_SHARING_ENABLED` | `false` | Enable cross-map intel sync | +| `WANDERER_KILLS_IPV6` | `false` | Resolve the kills websocket host over IPv6 | +| `DISCORD_BOT_TOKEN` | โ€” | Nostrum bot token; blank is treated as unset | +| `DISCORD_GUILD_ID` | โ€” | Guild for voice-participant lookups | +| `WANDERER_DISCORD_MENTIONS_ENABLED` | `true` | Master switch for mentions | +| `WANDERER_DISCORD_MAX_KILLMAIL_AGE_SECONDS` | `3600` | Drop killmails older than this | +| `WANDERER_DISCORD_POOL_SIZE` | `10` | Finch pool size for webhook delivery | +| `WANDERER_NOTABLE_ITEMS_ENABLED` | `false` | Enrich embeds with high-value drops | +| `WANDERER_NOTABLE_ITEMS_THRESHOLD_ISK` | `50000000` | Minimum item value to list | +| `WANDERER_NOTABLE_ITEMS_LIMIT` | `5` | Max items per embed | +| `WANDERER_NOTABLE_ITEMS_TIMEOUT_MS` | `1500` | Per-batch ESI budget | +| `WANDERER_CORP_TICKERS_ENABLED` | `true` | Resolve missing corp tickers from ESI | +| `WANDERER_CORP_TICKERS_TIMEOUT_MS` | `1500` | Per-element ESI budget | +| `SIGNATURE_WORMHOLE_EXPIRATION_HOURS` | `24` | Hours until wormhole signatures expire (`0` disables) | +| `SIGNATURE_DEFAULT_EXPIRATION_HOURS` | `72` | Hours until other signatures expire (`0` disables) | + +### Pinned dependencies + +| Dependency | Constraint | Reason | +|------------|-----------|--------| +| `phoenix_gen_socket_client` | `== 4.0.0` | `Kills.Transport.WebSocketClient` depends on its private handler-state shape | +| `gettext` | `~> 0.26` | `WandererAppWeb.Gettext` uses `Gettext.Backend`, absent before 0.26 | +| `nostrum` | `~> 0.10`, `runtime: false` | Voice mentions only; `:load` in the release | + +--- + +## Commit Convention + +Fork-only work uses **`zoo(): `**: + +```text +zoo(feat): add voice-channel mentions to route alerts +zoo(fix): stop one slow static lookup from killing the whole request +zoo(chore): drop the dead fleet-readiness flag +zoo(docs): document the intel-sharing config surface +``` + +Types: `feat`, `fix`, `chore`, `docs`, `refactor`, `perf`, `test`, `build`, `ci`. + +### Why the type sits in the scope position + +This inverts the Angular format in `.gitmessage`, which is `()`. That is +deliberate. The fork's dominant recurring question is *"which commits are ours?"* โ€” every +upstream merge, every extraction for an upstream PR, and every `git blame` into a diverged +subsystem needs that answer. Putting `zoo` first makes it a prefix match: + +```bash +git log --grep '^zoo(' upstream/main..HEAD # everything fork-only +git log --grep -v '^zoo(' upstream/main..HEAD # candidates to send upstream +``` + +With `feat(zoo):` the marker is buried mid-subject and the same query needs a regex that also +matches `fix(zoo)`, `chore(zoo)`, and so on. + +### When *not* to use it + +**Anything intended for upstream keeps the plain Angular format** โ€” `fix(routes): โ€ฆ`, +`feat(api): โ€ฆ`. The `zoo(` prefix is the signal that a commit is *not* upstreamable, so +applying it to a commit you plan to send upstream defeats the whole mechanism and means the +message has to be rewritten at extraction time. + +If a change is partly both, split it: the upstreamable part gets a plain-format commit, the +fork-specific remainder gets `zoo(โ€ฆ)`. + +### Everything else from `.gitmessage` still applies + +100-character wrapping, imperative present tense, and a body explaining *why* rather than +restating the diff. The body is mandatory except for `docs`, and at least 20 characters when +required. + +### Enforcement + +None currently โ€” the repo has no commitlint or husky config, so this is convention only. A +`commit-msg` hook rejecting subjects that match neither `^zoo\((feat|fix|chore|docs|refactor|perf|test|build|ci)\):` +nor the plain Angular pattern would enforce it, at the cost of imposing a hook on every +contributor. Not added yet. + +--- + +## Upstream PR Recommendations + +### Tier 1: Strongly Recommend + +Generic bug fixes and infrastructure with no zoo coupling. Each is independently PR-able. + +#### 1. Route static-data lookups can kill the caller (crash fix) + +`Task.async_stream/3` was used on default options in `map_routes.ex` and `routes_by.ex`. +The default is `on_timeout: :exit`, and `async_stream` links its tasks, so a single +`CachedInfo.get_system_static_info/1` overrunning the 5s default killed the **calling** +process. For `Routes.find/5` the caller is a plain `Task.async` linked to the LiveView, +which does not trap exits โ€” so one slow lookup remounted the user's whole map session. The +`Enum.map(fn {:ok, val} -> val end)` collector also FunctionClauseErrors on `{:exit, _}`. + +**Upstream still has both sites unfixed** (`map_routes.ex:59`, `routes_by.ex:165`). +**Scope:** `cached_info.ex`, `map_routes.ex`, `routes_by.ex`, new `route_static_data.ex`, tests. +**Commit:** `03454f669` (#127). + +#### 2. ESI access-token checks raise on characters with no token + +`is_access_token_expired?/1` did `{:ok, %{expires_at: expires_at}} = get_character(id)` and +then arithmetic on `expires_at`. `expires_at` is nullable, and `get_character/1` answers +`{:ok, nil}` / `{:error, :not_found}` โ€” so unauthenticated or unknown characters raised +MatchError/ArithmeticError on **every** authenticated ESI call (location, online, ship, +wallet, search), not just the corporation search where it was noticed. `time_since_expiry/1` +had the same `DateTime.from_unix!(nil)` problem. + +**Scope:** `lib/wanderer_app/esi/api_client.ex` + tests. **Commit:** within `cb2b8d024` (#103). + +#### 3. GarbageCollector aborts on stale records + +`Ash.bulk_destroy!` raises when a concurrently-deleted row turns up, killing the whole +scheduled cleanup. The fork switches to `Ash.bulk_destroy/4` with `return_errors?: true`, +filters `Ash.Error.Changes.StaleRecord`, and logs the rest. + +**Scope:** `lib/wanderer_app/map/map_garbage_collector.ex`. +**Note:** a code comment references a tuple-matching `case` that only ever existed on zoo โ€” +reword it before submitting. + +#### 4. Map duplication copies non-acceptable attributes + +`copy_single_system/2` and `copy_single_connection/3` built attribute maps by +`Map.from_struct |> Map.drop(denylist)`. Any attribute added to the resource and not added to +the denylist leaks into `create`. Replaced with an allowlist derived from what the target Ash +action actually accepts. + +**Scope:** `lib/wanderer_app/map/operations/duplication.ex`. + +#### 5. Parallel, correctly-cached CI + +Upstream runs one job doing format + compile + test + coverage + Credo + Dialyzer serially. +The fork splits it into `setup` / 4-way partitioned `tests` / `static` / `dialyzer` / +`coverage` and fixes the dependency caches. This is a straight wall-clock win for upstream. + +**Scope:** `.github/workflows/test.yml`, `config/test.exs` (partition count). +**Commit:** `e815c2283` (#121). + +#### 6. Charlist deprecation in two migrations + +`default: '{wormholes}'` โ†’ `default: ~c"{wormholes}"` in `20260331192521` and `20260406213852`. +Identical emitted DDL; removes a deprecation warning. Two-line PR that also removes a +permanent merge-conflict point for every fork. + +#### 7. IPv6-reachable route builder + +`RouteBuilderClient` sets `inet6: true` (Mint keeps `inet4: true`, so IPv4 hosts still work +after one failed resolution). Makes the route builder reachable on IPv6-only internal +networks. Frame the PR as "support IPv6 route-builder hosts", not as a Fly change. + +**Scope:** `lib/wanderer_app/route_builder_client.ex`, `lib/wanderer_app/esi/api_client.ex`. + +#### 8. On-demand signature cleanup + +Configurable, complements the existing GarbageCollector rather than replacing it. + +**Scope:** `signature_cleanup.ex`, `map_signatures_event_handler.ex`, `config/config.exs`. + +#### 9. `GET /api/acls/:acl_id/members/:member_id` + +The ACL member API supports create/update/delete but not read. The fork adds `show` with a +full OpenApiSpex schema covering the 404/409/500 branches `with_membership/4` can produce. + +**Scope:** `access_list_member_api_controller.ex`, `router.ex`. Drop the `*_v1` alias +functions โ€” those exist only for the fork's versioned router. + +#### 10. `JsonApiFormatter` payload contract + +`fix(json-api): correct JsonApiFormatter payload contract against real producers` (#105) +reworked ~575 lines of `external_events/json_api_formatter.ex` against what the actual event +producers emit. This is an upstream file with upstream consumers โ€” worth a PR, but it needs +its own before/after write-up since it changes emitted payloads. + +### Tier 2: Consider with Modifications + +| Feature | Blocker | +|---------|---------| +| Discord notifications | ~4,600 lines, 2 tables, a new dependency, and a Nostrum gateway. Open a design issue before writing the PR; consider splitting delivery (worker/router/embed) from voice mentions and route alerts. | +| Intel sharing | Generally useful, but needs upstream buy-in on the `intel_source_map_id` model and the `inherited_from_map_id` marker before the schema lands. | +| Fleet readiness | Generalize to a "character tags" system first. | +| Configurable label system | Needs labels to become theme-aware; discussion issue first. | +| `/health` endpoint | Trivially useful, but the router-ordering constraint and the deliberately unprotected pipeline need to be explained, not just merged. | + +### Tier 3: Keep Zoo-Only + +| Feature | Reason | +|---------|--------| +| Zoo theme | Highly specific to EVE wormhole gameplay | +| System ownership | Specific to tracking wormhole space occupation | +| Custom flags | Generic "store anything" field lacks structure | +| Connection loop type | Niche EVE mechanic | +| Fly.io deployment / gated deploy workflow | Deployment-target specific | + +--- + +## Key Files Reference + +### Frontend (Zoo-Specific) + +```text +assets/js/hooks/Mapper/ +โ”œโ”€โ”€ components/map/ +โ”‚ โ”œโ”€โ”€ styles/zoo-theme.scss +โ”‚ โ”œโ”€โ”€ labelIconMap.tsx +โ”‚ โ”œโ”€โ”€ constants.ts (modified) +โ”‚ โ””โ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ SolarSystemNode/SolarSystemNodeZoo.tsx +โ”‚ โ”œโ”€โ”€ SolarSystemNode/SolarSystemNodeZoo.module.scss +โ”‚ โ””โ”€โ”€ ZooIcons/ +โ”œโ”€โ”€ components/mapRootContent/components/FleetReadiness/ +โ””โ”€โ”€ types/connection.ts (ConnectionType.loop added) +``` + +### Backend (Zoo-Specific) + +```text +lib/wanderer_app/ +โ”œโ”€โ”€ external_events/ +โ”‚ โ”œโ”€โ”€ discord_dispatcher.ex +โ”‚ โ””โ”€โ”€ discord/ # 14 modules, see Discord Notifications +โ”œโ”€โ”€ map/ +โ”‚ โ”œโ”€โ”€ intel_sync.ex +โ”‚ โ”œโ”€โ”€ signature_cleanup.ex +โ”‚ โ”œโ”€โ”€ route_alert/evaluator.ex +โ”‚ โ””โ”€โ”€ route_static_data.ex # extracted by the routes timeout fix +โ”œโ”€โ”€ api/ +โ”‚ โ”œโ”€โ”€ map_system.ex (owner_*, custom_flags attributes) +โ”‚ โ””โ”€โ”€ map_user_settings.ex (ready_characters attribute) +โ””โ”€โ”€ repositories/ + โ”œโ”€โ”€ map_system_repo.ex (update_owner function) + โ””โ”€โ”€ map_user_settings_repo.ex (ready_characters functions) + +lib/wanderer_app_web/ +โ”œโ”€โ”€ controllers/health_controller.ex +โ””โ”€โ”€ live/map/event_handlers/ + โ”œโ”€โ”€ map_systems_event_handler.ex (ticker fetching) + โ”œโ”€โ”€ map_signatures_event_handler.ex (cleanup_expired_signatures) + โ””โ”€โ”€ map_characters_event_handler.ex (fleet readiness) +``` + +### Design docs + +```text +docs/superpowers/ +โ”œโ”€โ”€ specs/2026-08-02-flyio-migration-design.md +โ”œโ”€โ”€ specs/2026-08-07-deploy-approval-gate-design.md +โ”œโ”€โ”€ specs/2026-08-07-discord-route-alerts-design.md +โ”œโ”€โ”€ specs/2026-08-07-discord-voice-mentions-design.md +โ””โ”€โ”€ plans/โ€ฆ # one plan per spec +``` + +--- + +## Maintenance Notes + +### Merge Conflict Hotspots + +When merging upstream, watch for conflicts in: + +1. `priv/repo/migrations/` โ€” especially any upstream `FixMapsScopesDefault`; see the warning above +2. `config/runtime.exs` โ€” the fork adds ~65 lines at the tail and rewrites the host/URL block +3. `mix.exs` โ€” pinned `phoenix_gen_socket_client`, `gettext` floor, `nostrum`, release applications +4. `lib/wanderer_app_web/router.ex` โ€” the `/health` scope's position +5. `lib/wanderer_app/external_events/json_api_formatter.ex` โ€” heavily rewritten +6. `assets/โ€ฆ/map/constants.ts` โ€” label and bookmark style changes +7. `lib/wanderer_app/api/map_system.ex` โ€” attribute additions +8. `priv/resource_snapshots/` โ€” regenerate rather than merge + +### Keeping the fork current + +```bash +git fetch upstream --prune + +# behind / ahead, against both upstream branches -- they diverge, so check each +for ref in upstream/main upstream/develop; do + printf '%-18s ' "$ref" + git rev-list --left-right --count "$ref"...origin/guarzo/zoo # behindahead +done +``` + +The first column is how many commits the fork is **behind** that ref; the second is how many +it is **ahead**. As of this document the fork is 0 behind both `upstream/main` and +`upstream/develop`. + +### Testing + +```bash +# Test migration idempotency +MIX_ENV=test mix ecto.reset +MIX_ENV=test mix ecto.migrate +MIX_ENV=test mix ecto.migrate # Should not fail + +# Verify configuration loads +MIX_ENV=dev iex -S mix -e "IO.inspect(Application.get_env(:wanderer_app, :signatures))" + +# Build frontend +cd assets && yarn build +``` diff --git a/docs/prompts-2026-08-08.md b/docs/prompts-2026-08-08.md new file mode 100644 index 000000000..63c0aae03 --- /dev/null +++ b/docs/prompts-2026-08-08.md @@ -0,0 +1,1097 @@ +# Fork Follow-Up Prompts + +Generated 2026-08-08 from a review of `guarzo/zoo` against `wanderer-industries/wanderer`. + +Each numbered prompt below is **self-contained** and written for a fresh session with no +prior context. Start one with: + +> read `docs/prompts-2026-08-08.md`, work on prompt 3 + +Do not paraphrase the prompt to the session โ€” the whole point is that it reads the file +itself, including the shared context and ground rules. + +--- + +## Shared context (every prompt depends on this) + +| Fact | Value | +|------|-------| +| Fork repo | `guarzo/wanderer` โ€” remote `origin` | +| Upstream repo | `wanderer-industries/wanderer` โ€” remote `upstream` | +| Fork branch | `guarzo/zoo` | +| Upstream baseline at review time | `upstream/main` @ `b7ddbc486`, `upstream/develop` @ `35ea4e5f1` โ€” re-checked 2026-08-08 post-rewrite, both unmoved | +| Divergence at review time | 82 commits / 322 files ahead of `upstream/main`; 242 ahead of `upstream/develop`; 0 behind either | +| Fork documentation | `docs/ZOO-FORK.md` โ€” read the relevant section before starting | + +> **History rewrite, 2026-08-08.** `guarzo/zoo` was force-pushed to split its three +> mega-commits into 18 feature commits. Every commit SHA above the fork point changed, so +> most SHAs originally cited in this document were left unreachable. They have been remapped +> in place โ€” the oldโ†’new table is under "Superseded: the history rewrite" in prompt 14. +> The pre-rewrite history is preserved at `origin/guarzo/zoo-prerewrite`. If you have an +> existing checkout, run `git fetch origin && git reset --hard origin/guarzo/zoo` โ€” **not** +> `git pull`, which would merge the old history back in. +> +> The ahead count grew 58 โ†’ 82 for two reasons, only one of which is real work: the split +> itself added 15 commits without changing a single byte of the tree. + +Re-verify the baseline before you start, because it moves. Check every fact in the table +above, not just the ahead count: + +```bash +git fetch upstream --prune +git fetch origin --prune + +# behind / ahead, against both upstream branches -- they diverge, so check each +for ref in upstream/main upstream/develop; do + printf '%-18s ' "$ref" + git rev-list --left-right --count "$ref"...origin/guarzo/zoo # behindahead +done + +# the 312-file claim +git diff --name-only upstream/main...origin/guarzo/zoo | wc -l +``` + +If any of the three numbers has moved materially, note the new values in your report โ€” later +prompts quote them. If the fork is now *behind* either upstream branch, say so and stop: +several of these prompts assume the upstream code still contains the bug being fixed, and that +assumption needs rechecking first. + +--- + +## Ground rules for every prompt + +1. **Work in a linked worktree.** Do not write to the primary checkout. One worktree per + prompt โ€” that is what makes these safe to run in parallel. +2. **Never push and never open a PR without asking.** Prompts 1โ€“10 end at "branch is ready, + PR body is drafted". Opening a PR against someone else's repository is the human's call. + Prepare everything, show the diff and the draft body, then stop. +3. **Do not use the git stash.** The stash stack is shared across worktrees and other + sessions may be using it. Use a WIP commit instead. +4. **Verify before claiming.** Run the checks listed in the prompt and report actual output. + If a check fails or you could not run it, say which one and why. Never report a command + as passing that you did not run. +5. **Stay in scope.** These are deliberately small. If you find an adjacent problem, write it + down in your report rather than fixing it. + +### Useful commands + +```bash +mix format --check-formatted +mix credo +mix test # full suite: ~1595 tests at review time +mix test test/unit/some_test.exs # focused +mix dialyzer # 153 pre-existing lib warnings is the known baseline +make format # = mix format +cd assets && yarn build # frontend +``` + +--- + +## โš  The single most important warning + +**Most of these changes cannot be cherry-picked.** They are either buried inside three +undescriptive mega-commits, or they share a file with fork-only work. A naive +`git cherry-pick` or a path-scoped `git diff upstream/main...origin/guarzo/zoo -- ` +will drag zoo-only code into what is supposed to be a clean upstream PR. + +Worked example โ€” `lib/wanderer_app/map/map_routes.ex` diverges from upstream by ~135 lines, +but only ~37 of those are the timeout fix in prompt 1. The rest is `find_strict/5`, which +exists solely to serve zoo's Discord route alerts and must **not** go upstream. + +So for every extraction prompt: start from the path diff to *find* the change, then +hand-assemble the minimal patch, then confirm the result compiles and tests against +`upstream/main` **without** any zoo module in scope. + +--- + +## Parallel-safety matrix + +Prompts that touch a shared file must not run at the same time. Everything else is +independent. + +| Prompt | Primary files | Conflicts with | +|--------|---------------|----------------| +| 1 | `map_routes.ex`, `routes_by.ex`, `cached_info.ex`, `route_static_data.ex` | โ€” | +| 2 | `esi/api_client.ex` | **7** | +| 3 | `map_garbage_collector.ex` | โ€” | +| 4 | `map/operations/duplication.ex` | โ€” | +| 5 | `.github/workflows/test.yml` | โ€” | +| 6 | two migration files | โ€” | +| 7 | `route_builder_client.ex`, `esi/api_client.ex` | **2** | +| 8 | `signature_cleanup.ex`, `map_signatures_event_handler.ex`, `config/config.exs` | โ€” | +| 9 | `access_list_member_api_controller.ex`, `router.ex` | โ€” | +| 10 | `external_events/json_api_formatter.ex` | โ€” | +| 11 | `config/runtime.exs`, `docs/ZOO-FORK.md` (fork branch) | **13**, **14** | +| 12 | `.github/` CI check, possibly `docs/ZOO-FORK.md` (fork branch) | **11**, **13**, **14** | +| 13 | `docs/ZOO-FORK.md` (fork branch) | **11**, **12**, **14** | +| 14 | `docs/ZOO-FORK.md` (fork branch) | **11**, **12**, **13** | +| 15 | none (branch deletion) | โ€” | + +**Prompts 2 and 7 both edit `lib/wanderer_app/esi/api_client.ex`.** Run 2 first, then 7 โ€” +or run them together under prompt 2's instructions and split into two commits. + +**Prompts 11, 12, 13, and 14 can all edit `docs/ZOO-FORK.md`.** Prompt 12 only conflicts if +it takes the documentation option rather than the CI-check option; prefer the CI check, which +solves the whole class of problem and keeps 12 independent. If you do take the doc option, +sequence 12 after 11, 13, and 14. + +### Handing off between prompts that share a file + +Sequencing is not enough on its own โ€” the second prompt needs the first one's changes, and +"run 2 first" does not say how they meet. Use **stacked branches**: + +```bash +# session A (prompt 2) +git worktree add ../wt-p2 -b zoo/p2-esi-token upstream/main +# ...work, commit, stop... + +# session B (prompt 7) starts from A's branch, not from upstream/main +git worktree add ../wt-p7 -b zoo/p7-ipv6 zoo/p2-esi-token +``` + +Prompt 7's branch then contains both commits. When prompt 2's PR merges upstream, rebase +prompt 7 onto the updated `upstream/main` and its commit stands alone. If prompt 2 is +*rejected* upstream, prompt 7 must be rebased off it before submission โ€” check that before +opening the second PR. + +The same applies to the `docs/ZOO-FORK.md` group: stack 13 on 11, and 14 on 13. + +Do **not** run two shared-file prompts concurrently in separate worktrees and merge afterwards. +Both will have edited the same region and you will resolve the conflict with no memory of +either session's reasoning. + +Prompts 1โ€“10 each produce an independent branch off `upstream/main` (or off the branch they +stack on, per above), so they never conflict with each other in the fork. Prompts 11โ€“14 branch +off `origin/guarzo/zoo`. + +--- + +# Group A โ€” Upstream PR candidates (prompts 1โ€“10) + +Every prompt in this group has the same shape: + +1. Create a worktree and branch **from `upstream/main`**, not from `guarzo/zoo`. +2. Reconstruct the minimal change (see the warning above). +3. Confirm the bug/gap still exists upstream before writing the fix. If upstream has already + fixed it, stop and report that โ€” do not open a redundant PR. +4. Include tests. Upstream will not take an unproven crash fix. +5. Run `mix format --check-formatted`, `mix credo`, and the relevant tests. +6. Draft the PR title and body. Explain the failure mode, not the patch. +7. **Stop. Show the diff and the draft. Ask before pushing.** + +--- + +## Prompt 1 โ€” Route static-data lookups can kill the calling process + +**This is the highest-value item in the list. Do it first if you only do one.** + +### The bug + +`Task.async_stream/3` is called with default options in two places. The default is +`on_timeout: :exit`, and `async_stream` **links** its tasks โ€” so a single +`WandererApp.CachedInfo.get_system_static_info/1` call that overruns the 5-second default +kills the *calling* process, not just the one lookup. + +For `Routes.find/5` the caller is a plain `Task.async` linked to the LiveView, which does not +trap exits. One slow lookup therefore remounts the user's entire map session. Separately, the +collector `Enum.map(fn {:ok, val} -> val end)` raises FunctionClauseError the moment any +element yields `{:exit, _}`. + +### Verified upstream locations + +- `lib/wanderer_app/map/map_routes.ex:59` +- `lib/wanderer_app/map/routes_by.ex:165` (inside `fetch_systems_static_data/1`) + +Confirm both are still present on the current `upstream/main` before proceeding. + +### Where the fix lives on the fork + +Commit `3d38754f4` (#127) โ€” **this one is unusually clean**. `cached_info.ex` and +`routes_by.ex` are touched by that commit and nothing else, so those two files can be taken +almost as-is. + +`map_routes.ex` **cannot**. It also carries `find_strict/5`, which exists only for zoo's +Discord route alerts. Take the `hydrate_static_data/2` extraction and the `find/5` changes; +leave `find_strict/5` behind. + +`lib/wanderer_app/map/route_static_data.ex` is a new module introduced by that commit. Decide +whether upstream wants the extraction or just the inline fix โ€” the extraction is cleaner but +it is a bigger ask. Presenting the inline fix as the PR and mentioning the extraction as a +follow-up is the safer play. + +### Approach + +Switch to `on_timeout: :kill_task` and pair it with a collector that handles `{:exit, _}` as +well as `{:ok, _}`, dropping the timed-out entries rather than failing the batch. + +### Tests + +`test/unit/cached_info_static_info_test.exs` and +`test/unit/map/map_routes_find_strict_test.exs` on the fork cover this. Port the parts that +apply to `find/5`; the `find_strict/5` tests are zoo-only. Make sure a test actually blocks a +named system long enough to trigger the timeout path โ€” that is the assertion that fails +without the fix. + +### Done when + +The reconstructed branch compiles against `upstream/main` with no reference to any zoo +module, a test demonstrably fails without the fix and passes with it, and the PR body +explains the LiveView-remount failure mode in user-visible terms. + +--- + +## Prompt 2 โ€” `is_access_token_expired?/1` raises for characters without a token + +โš  **Conflicts with prompt 7** (same file). Run this one first. + +### The bug + +In `lib/wanderer_app/esi/api_client.ex`, upstream has: + +```elixir +defp is_access_token_expired?(character_id) do + {:ok, %{expires_at: expires_at} = _character} = + WandererApp.Character.get_character(character_id) + + now = DateTime.utc_now() |> DateTime.to_unix() + expires_at - now <= 0 +end +``` + +Three ways this blows up: + +- `expires_at` is nullable on `WandererApp.Api.Character`, so a character that never completed + an OAuth exchange carries `nil` โ†’ ArithmeticError. +- `WandererApp.Character.get_character/1` answers `{:ok, nil}` for a nil id โ†’ MatchError. +- โ€ฆand `{:error, :not_found}` for an id not in the cache or the DB โ†’ MatchError. + +This fires on **every** authenticated ESI call โ€” location, online, ship, wallet, search โ€” not +just the corporation search where it was originally noticed. + +`time_since_expiry/1` in the same module has the matching `DateTime.from_unix!(nil)` problem. + +### Where the fix lives on the fork + +Inside squash commit `a010cb479` (#103), which also contains unrelated corporation-typeahead +work. **Extract, do not cherry-pick.** The file is also touched by `0518a96cb` (#112, the +IPv6 change โ€” that is prompt 7) and by the killmail-notifications region, +`1b4a7e2aa`..`7f819f1c5` (formerly the `e791d4e10` mega-commit). + +### Approach + +Only an integer `expires_at` in the future counts as not-expired. Every other shape means +validity cannot be proven, so report expired and let the caller take the refresh-and-retry +path. `time_since_expiry/1` is diagnostic only and must degrade to `nil` rather than raise โ€” +timing instrumentation must never be the thing that fails a request. + +Note the fork made both functions public with `@doc false` purely so tests could reach them. +Flag that to upstream rather than doing it silently; they may prefer a different test seam. + +### Done when + +Both public helpers are covered: + +- `is_access_token_expired?/1` โ€” a test constructs each of the three bad shapes (`expires_at` + is `nil`, `get_character/1` answers `{:ok, nil}`, `get_character/1` answers + `{:error, :not_found}`) and shows the old code raising and the new code returning `true`. +- `time_since_expiry/1` โ€” a test passes `nil` and each non-integer shape from that same list + and asserts the function returns `nil` without raising. This helper is diagnostic, so the + assertion that matters is *that it does not raise*; the returned value only needs to be + `nil` rather than a number. + +Leave the existing `is_access_token_expired?/1` coverage as-is if upstream already has some โ€” +add to it rather than rewriting it. + +--- + +## Prompt 3 โ€” GarbageCollector aborts the whole run on a stale record + +### The bug + +`lib/wanderer_app/map/map_garbage_collector.ex` uses `Ash.bulk_destroy!` for both +`cleanup_chain_passages/0` and `cleanup_system_signatures/0`. The bang variant raises when a +concurrently-deleted row turns up, which kills the entire scheduled cleanup โ€” so one racing +delete means no garbage is collected that day. + +### Where the fix lives on the fork + +**Only in the former mega-commit regions** (`6ebdb3010`..`032eec9c7`, was `defb24439`; +`1b4a7e2aa`..`7f819f1c5`, was `e791d4e10`) plus `aabeead2e`. Since the rewrite the region is +navigable, so check whether one of the 18 split commits now isolates this change before +falling back โ€” `git log --oneline -- ` over the range will tell you. If it still spans +several, reconstruct from the current tree: + +```bash +git diff upstream/main...origin/guarzo/zoo -- lib/wanderer_app/map/map_garbage_collector.ex +``` + +### Approach + +Switch to non-bang `Ash.bulk_destroy/4` with `return_errors?: true`, filter out +`Ash.Error.Changes.StaleRecord` (both bare and wrapped in `Ash.Error.Invalid`), log the rest +as warnings, and count only the stale ones as race conditions. + +### โš  Fix before submitting + +The fork's version carries this comment: + +> `Ash.bulk_destroy/4` returns a bare `%Ash.BulkResult{}`, never an `{:ok, _}` / `{:error, _}` +> tuple, so the previous tuple-matching `case` raised CaseClauseError on every run. + +That "previous tuple-matching `case`" **only ever existed on zoo**. Upstream goes straight +from `bulk_destroy!` to the new code, so the comment describes history upstream does not +have. Reword it before submitting. + +--- + +## Prompt 4 โ€” Map duplication copies attributes the create action does not accept + +### The bug + +In `lib/wanderer_app/map/operations/duplication.ex`, both `copy_single_system/2` and +`copy_single_connection/3` build their attribute map as +`Map.from_struct() |> Map.drop(excluded_fields)` โ€” a hand-maintained denylist of system +fields and Ash/Ecto metadata. + +Any attribute added to `MapSystem` or `MapConnection` that nobody remembers to add to the +denylist leaks straight into `create`. The denylist is duplicated verbatim in both functions, +so it can also drift against itself. + +### Where the fix lives on the fork + +Mega-commits only. Reconstruct from: + +```bash +git diff upstream/main...origin/guarzo/zoo -- lib/wanderer_app/map/operations/duplication.ex +``` + +### Approach + +Replace the denylist with an allowlist derived from what the target Ash action actually +accepts (`acceptable_attrs/3` in the fork's version). This inverts the failure mode: a new +attribute is silently *not* copied until someone opts it in, instead of being silently copied +into an action that may reject it. + +Call that tradeoff out explicitly in the PR body โ€” it is a behaviour change, and a reviewer +will want to have thought about it. + +--- + +## Prompt 5 โ€” Split the CI test suite into parallel jobs + +### The gap + +Upstream's `.github/workflows/test.yml` is a single job running format โ†’ compile โ†’ test โ†’ +coverage โ†’ Credo โ†’ Dialyzer serially. Everything waits on everything. + +### The fork's version + +Commit `708d601b3` (#121) restructures it into: + +- `setup` โ€” installs and compiles deps once, seeds the shared cache +- `tests` โ€” a 4-way `mix test --partitions` matrix +- `static` โ€” format check, warning count, Credo +- `dialyzer` โ€” PLT build + run +- `coverage` โ€” coverage report and PR comment + +It also fixes the dependency caches, which were the reason the fan-out did not help before. + +### โš  Strip the zoo-specific parts + +`test.yml` is also touched by `66cefb9ac` (#117), which added `guarzo/zoo` branch triggers, +and by `aabeead2e` (#118). The upstream PR must contain **only** the structural change โ€” no +zoo branch names anywhere in `on:`. + +### Coupling to preserve + +The `PARTITIONS` env var must match the length of the `partition` matrix list. `config/test.exs` +already suffixes the database name with `MIX_TEST_PARTITION` (that is upstream's own default), +so no config change is needed โ€” but say so in the PR body so a reviewer does not go looking. + +### Done when + +The workflow is valid YAML, contains no zoo references, and the PR body quotes before/after +wall-clock numbers if you can get them. + +--- + +## Prompt 6 โ€” Charlist deprecation in two migrations + +The smallest item here. Good warm-up. + +### The change + +In both of these files: + +- `priv/repo/migrations/20260331192521_add_mass_to_map_chain_passages.exs` +- `priv/repo/migrations/20260406213852_add_character_description.exs` + +change: + +```elixir +modify :scopes, {:array, :text}, default: '{wormholes}' +``` + +to: + +```elixir +modify :scopes, {:array, :text}, default: ~c"{wormholes}" +``` + +### Why it is worth a PR despite being trivial + +The emitted DDL is byte-identical, so there is zero migration risk. It silences the Elixir +single-quoted-charlist deprecation, and โ€” the actual argument โ€” it removes a file that +currently conflicts on **every** merge for every downstream fork, because every fork has to +make this same edit locally. + +Lead the PR body with the fork-maintenance argument, not the deprecation warning. + +### Note + +Editing already-applied migrations is normally a smell. Say explicitly in the PR body that +the generated SQL is unchanged, so nobody has to work that out for themselves. + +--- + +## Prompt 7 โ€” Support IPv6-reachable route builder hosts + +โš  **Conflicts with prompt 2** (same file). Run prompt 2 first. + +### The gap + +Mint resolves IPv4-only unless told otherwise. On any deployment where the route builder is +reachable only over IPv6 โ€” Fly's 6PN `.internal` addresses have no A record at all โ€” every +request fails with `:nxdomain`. Callers see an ordinary route-lookup failure and the ESI +fallback reports "no connection" for every hub, so **nothing in the logs names DNS**. That +diagnostic dead-end is the real cost. + +### The fix on the fork + +Commit `0518a96cb` (#112) adds to `lib/wanderer_app/route_builder_client.ex`: + +```elixir +@connect_opts [connect_options: [transport_opts: [inet6: true]]] +def connect_opts, do: @connect_opts +``` + +merged into the existing `@timeout_opts` at each call site, and reused by +`lib/wanderer_app/esi/api_client.ex` which posts to `/route/multiple` on the same service. + +### Why unconditional rather than a flag + +`inet6: true` keeps Mint's `inet4: true` default, so it tries IPv6 first and falls back to +IPv4. Existing IPv4 docker-compose deployments keep working at the cost of one failed +resolution per new connection. A flag left unset fails silently, which is the exact bug being +fixed โ€” make that argument in the PR body, because a reviewer will ask for a flag. + +### Framing + +Present this as "support IPv6 route-builder hosts", **not** as a Fly.io change. Nothing about +the patch is Fly-specific and framing it that way invites a "we don't use Fly" rejection. + +--- + +## Prompt 8 โ€” Configurable on-demand signature cleanup + +### The feature + +Upstream expires signatures via a daily batch `GarbageCollector` at a hardcoded 14 days. The +fork adds `WandererApp.Map.SignatureCleanup` (`lib/wanderer_app/map/signature_cleanup.ex`), +invoked as `cleanup_async/1` from `map_signatures_event_handler.ex:199` and `:239` when a user +views or updates signatures. It is per-system, configurable, and can preserve signatures that +still have connections. + +The two mechanisms compose rather than compete: the on-demand pass deletes aggressively on +interaction, and the daily batch remains the safety net for systems nobody visits. + +### Config surface + +```elixir +config :wanderer_app, :signatures, + wormhole_expiration_hours: 24, # env: SIGNATURE_WORMHOLE_EXPIRATION_HOURS + default_expiration_hours: 72, # env: SIGNATURE_DEFAULT_EXPIRATION_HOURS + preserve_connected: true + +config :wanderer_app, :signature_cleanup, max_age_hours: 24 +``` + +`0` disables cleanup for that signature type. + +### Where it lives + +Mega-commits only โ€” reconstruct from the tree. Also read `lib/wanderer_app/map/README.md:52`, +which documents the interaction and should be part of the PR. + +### Before you start + +Check whether upstream has since made the GarbageCollector thresholds configurable. If they +have, this PR probably becomes "make the existing cleanup on-demand" rather than a new module, +which is a smaller and more likely-to-land change. + +### Note on defaults + +The fork's 24h/72h defaults are far more aggressive than upstream's 14 days. Propose defaults +that preserve upstream's current behaviour and let operators opt into the shorter windows โ€” +a PR that silently makes everyone's signatures vanish sooner will not land. + +--- + +## Prompt 9 โ€” Add `GET /api/acls/:acl_id/members/:member_id` + +### The gap + +The ACL member API supports create, update-role, and delete, but has no read endpoint. A +client that just created a member cannot fetch it back. + +### The fork's version + +Adds a `show/2` action to `lib/wanderer_app_web/controllers/access_list_member_api_controller.ex` +delegating to the existing `with_membership/4` helper, plus a route in `router.ex`: + +```elixir +get "/:acl_id/members/:member_id", AccessListMemberAPIController, :show +``` + +It ships a full OpenApiSpex schema covering all four statuses `with_membership/4` can actually +produce: 200, 404, 409 (more than one membership matches), 500. + +### โš  Strip before submitting + +The fork's diff also adds `show_v1`, `create_v1`, `update_role_v1`, `delete_v1` alias +functions. Those exist purely for the fork's versioned API router and must **not** go +upstream. + +### Where it lives + +Mega-commits. Reconstruct from the tree. + +### Done when + +The endpoint has request tests for the 200, 404, and 409 paths, and the OpenAPI spec still +generates cleanly. + +--- + +## Prompt 10 โ€” `JsonApiFormatter` payload contract + +**The riskiest item in this group. Read this whole prompt before starting.** + +### What it is + +`fix(json-api): correct JsonApiFormatter payload contract against real producers` +(`2321332cb`, #105) reworked ~575 lines of +`lib/wanderer_app/external_events/json_api_formatter.ex` so the +emitted payloads match what the event producers actually send, rather than what the formatter +assumed. + +This is an upstream file with upstream consumers โ€” external webhook and SSE subscribers. + +### Why it is risky + +It **changes emitted payloads**. Anyone consuming the current output may be depending on the +current (wrong) shape. This cannot be presented as a pure bugfix. + +### Good news + +`json_api_formatter.ex` is touched by **only** commit `2321332cb` on the fork, so unlike the +other prompts this one is a genuine clean cherry-pick candidate. Verify that is still true: + +```bash +git log --oneline upstream/main..origin/guarzo/zoo -- lib/wanderer_app/external_events/json_api_formatter.ex +``` + +### What the PR needs that the others do not + +- A before/after table of every payload shape that changes. +- An explicit statement of which changes are breaking for existing consumers. +- A recommendation on whether upstream should version the event format. + +Consider opening a **discussion issue first** with the before/after table and letting upstream +decide whether they want the PR. That is a legitimate outcome for this prompt โ€” do not force +it into a PR. + +--- + +# Group B โ€” Fork-side fixes (prompts 11โ€“13) + +These branch from `origin/guarzo/zoo` and stay in the fork. + +--- + +## Prompt 11 โ€” Resolve the dead `WANDERER_FLEET_READINESS_ENABLED` flag + +> **โœ… DONE โ€” landed in #136 (`05322156c`), 2026-08-08.** Resolved by *deleting* the flag, not +> wiring it. `git log -S fleet_readiness --all -- lib/ assets/` returns nothing, ever: no +> consumer was ever written, so there was no intent-to-gate to honour. Wiring it at its +> existing `"false"` default would have silently disabled fleet readiness for every current +> deployment. `docs/ZOO-FORK.md` now carries a "No feature flag." note in its place. +> Left below for the reasoning trail. + +### The finding + +`config/runtime.exs:86` parses `WANDERER_FLEET_READINESS_ENABLED` (default `"false"`) and +line 202 stores it as `:wanderer_app, :fleet_readiness_enabled`. **Nothing reads it.** Verify: + +```bash +grep -rn "fleet_readiness" --include='*.ex' --include='*.exs' --include='*.ts' --include='*.tsx' . | grep -v deps/ +``` + +At review time that returned exactly two hits, both in `config/runtime.exs`. + +So the fleet-readiness feature is unconditionally on, and an operator who sets the flag to +`false` gets no effect and no warning โ€” which is worse than not having the flag. + +### Two valid outcomes โ€” decide, then do one + +1. **Wire it up.** Add `Env.fleet_readiness_enabled?/0` mirroring + `Env.intel_sharing_enabled?/0` (`lib/wanderer_app/env.ex:22`), and gate the feature. Find + the real gate points first: the event handler in + `map_characters_event_handler.ex`, the `update_ready_characters` action in + `lib/wanderer_app/api/map_user_settings.ex`, and the frontend + `components/mapRootContent/components/FleetReadiness/`. Decide whether the flag hides the + UI, rejects the action, or both โ€” a UI-only gate is not a real feature flag. +2. **Delete it.** Remove both lines from `config/runtime.exs`. Correct if nobody wants the + flag; note that removing it is a silent behaviour no-op since nothing read it anyway. + +Default to option 1 only if you can find evidence someone intended to gate this โ€” check +`docs/ZOO-FORK.md` and the git history of `runtime.exs`. Otherwise option 2 is the honest fix. + +### Also update + +`docs/ZOO-FORK.md` has a "Known gap" callout under Fleet Readiness and a row in the env-var +table. Both must reflect whatever you decide. โš  Coordinate with prompts 13 and 14 if they +are running. + +--- + +## Prompt 12 โ€” Guard against the `FixMapsScopesDefault` migration collision + +> **โœ… DONE โ€” landed in #136 (`05322156c`), 2026-08-08.** A `Migration hygiene` job now fails +> CI on duplicate migration *module names*. It is pure shell with no BEAM or deps, so it +> reports in seconds rather than waiting on `setup`, and it is wired into the `Test Suite` +> gate (verified live: the gate log prints `migrations: success`). +> +> Why module names and not versions: `mix ecto.migrate` already rejects duplicate version +> numbers, so only duplicate module names with *distinct* timestamps slip through โ€” and in +> Elixir that is not a compile error. The later definition silently replaces the earlier, so +> a migration simply never runs. Validated against `a500f6e86^`, the real historical +> regression, not just the current clean tree. + +### The finding + +`priv/repo/migrations/20260801200000_fix_maps_scopes_default.exs` is on the fork. Commit +`a500f6e86` (#122) describes it as upstream's, but it is on **neither** `upstream/main` nor +`upstream/develop`. + +Search by **module name**, not by filename โ€” the whole risk here is upstream landing the same +module under a different timestamp, which a filename check would miss: + +```bash +for ref in upstream/main upstream/develop; do + echo "=== $ref ===" + git grep -n 'FixMapsScopesDefault' "$ref" -- priv/repo/migrations/ || echo "absent" +done +``` + +At review time both printed `absent`. It arrived via `aabeead2e` (#118), which pulled from an +upstream PR that had not merged. + +### Why this matters + +The fork previously carried its own fix for the same bug at `20260804190000`, also defining +`WandererApp.Repo.Migrations.FixMapsScopesDefault`. Ecto rejects duplicate migration **names** +outright: + +```text +** (Ecto.MigrationError) migrations can't be executed, + migration name fix_maps_scopes_default is duplicated +``` + +That aborts the entire migrator โ€” no migration runs at all. It is a hard break, not a +warning. #122 fixed it by deleting the fork's copy. If upstream later merges the same fix +under a different timestamp, **the collision comes straight back** on the next upstream merge. + +### The task + +This is an investigation-and-safeguard prompt, not a code change. In order: + +1. Confirm the current state โ€” has upstream merged a `FixMapsScopesDefault` yet, under any + timestamp? Check `upstream/main`, `upstream/develop`, and open upstream PRs. +2. If they have merged it at a different timestamp, **the fork is already broken on the next + merge**. Report that immediately and propose the resolution (drop the fork's copy, keep + upstream's) before doing anything else. +3. If they have not, add a safeguard. Options, in rough order of preference: + - A CI check that fails when two migration files define the same module name. This catches + the whole class of problem, not just this instance, and is maybe 15 lines. + - A note in the pre-merge checklist in `docs/ZOO-FORK.md` (already partly there under + Merge Conflict Hotspots). +4. Whatever you choose, verify it actually catches the failure โ€” construct the duplicate + locally, confirm the check fires, then remove the test case. + +### Also worth checking + +The same class of problem applies to the two upstream migrations the fork edits in place +(`20260331192521`, `20260406213852` โ€” see prompt 6). Those conflict on content rather than +module name, so the check above will not catch them. Mention this in your report. + +--- + +## Prompt 13 โ€” Audit the label-system section of the fork doc + +> **โœ… DONE โ€” landed in #136 (`05322156c`), 2026-08-08.** The audit found three real errors, +> all now corrected in `docs/ZOO-FORK.md`: +> +> 1. **Icons were swapped** between `crit` and `structure` in the doc table. The code was +> right; the doc was wrong. +> 2. **The storage claim was backwards.** The doc said the enum *member* names persist. It +> is the enum *values* โ€” `system.labels` contains `de`/`gas`/`eol`/`crit`/`structure`/ +> `steve`, never `la`/`lb`/`lc`. Traced to `labelIconMap.tsx`'s own header comment, which +> the doc had copied; that comment was corrected too, so the two cannot drift apart again. +> 3. **The styles pointer was incomplete.** `ZOO_BOOKMARK_STYLES` has no entry for +> `structure` or `steve`, which the doc implied it covered. +> +> One thing deliberately *not* asserted: `steve`'s short name `DB` has no expansion anywhere +> in the tree. Only `LP` = "Low Power Structure" is supported by a source comment. `DB` is +> described as historic rather than guessed at. + +### Why + +`docs/ZOO-FORK.md` was rewritten on 2026-08-08, but the Label System section was carried over +from the 2025-11-30 version **unverified**. Its six label mappings and the claim about how +labels are stored have not been checked against the code in nine months. + +โš  Conflicts with prompt 14 (same file). + +### What to verify + +Against `assets/js/hooks/Mapper/components/map/labelIconMap.tsx`, +`assets/js/hooks/Mapper/components/map/constants.ts`, and +`assets/js/hooks/Mapper/components/map/styles/zoo-theme.scss`: + +| Claim in the doc | Check | +|------------------|-------| +| `la`/`de` = Dead End, Block icon | Key, name, and icon all still match? | +| `lb`/`gas` = Gas Site, Industry icon | " | +| `lc`/`eol` = End of Life, Hourglass icon | " | +| `l1`/`crit` = Critical Mass, Fire icon | " | +| `l2`/`structure` = Structure, Warning icon | " | +| `l3`/`steve` = Steve/Danger, Skull icon | " | +| Labels stored under upstream keys, displayed with zoo names | Still true? | +| Zoo CSS uses the `eve-zoo-` prefix, four listed colours | Hex values still current? | +| `MARKER_BOOKMARK_BG_STYLES` lives in `constants.ts` | Still there, still that name? | + +Also check whether any labels have been **added** since โ€” the doc lists exactly six. + +### Done when + +Every row is either confirmed or corrected in `docs/ZOO-FORK.md`, and your report says +explicitly which ones were wrong. "All correct" is a fine outcome; say so plainly. + +--- + +# Group C โ€” Repository hygiene (prompts 14โ€“15) + +--- + +## Prompt 14 โ€” Settle the merge strategy and write it down + +โš  Conflicts with prompt 13 (same file). + +### The finding + +55 of the 58 fork commits are squash-merged PRs. Three are real merge commits (#124, #125, +and #128), and those three leaked roughly 15 WIP commits onto the mainline (`review: โ€ฆ`, +`polish: โ€ฆ`, `docs: โ€ฆ`, `fix(deps): restore original lock`) where every other feature +contributed exactly one commit. + +The repo has a `.gitmessage` template specifying 100-character wrapping, and the recent commit +messages follow it well. What is missing is a stated **merge** policy. + +### The task + +1. Confirm the current numbers: + ```bash + git rev-list --merges --count upstream/main..origin/guarzo/zoo + git rev-list --count upstream/main..origin/guarzo/zoo + ``` +2. Pick a policy. Squash matches existing practice 55-to-3 and is the recommended default. +3. Enforce it where it will actually hold: the repository's "Allow merge commits" setting on + GitHub is more reliable than a documented convention. +4. Document it โ€” a short "Contributing / merge policy" section in `docs/ZOO-FORK.md`, or a + `CONTRIBUTING.md` if you prefer it discoverable from the repo root. + +### Superseded: the history rewrite (done 2026-08-08) + +This section previously read **"Do not rewrite existing history."** That advice was overtaken +by events โ€” the rewrite was carried out deliberately on 2026-08-08. Recorded here so the +reversal is not mistaken for drift. + +The original reasoning was that a rebase would not recover the lost rationale and would +invalidate every published branch and open worktree. The first half still holds: nothing was +recovered, because nothing was recoverable. What changed is the second half โ€” the cost was +paid down first. Every PR was merged, no unmerged branch of consequence remained, and the +pre-rewrite tip was preserved at `origin/guarzo/zoo-prerewrite` before the force-push. + +The three mega-commits were split by chronological file grouping, which is sound here only +because they contain zero deletions and zero renames: + +| Was | Now | Commits | +|-----|-----|---------| +| `045388354` "share intel between maps", 42 files | `d1e2db50b` | 1 | +| `defb24439` "zoo: custom", 145 files | `6ebdb3010`..`032eec9c7` | 11 | +| `e791d4e10` "zoo: killmail notifications", 98 files | `1b4a7e2aa`..`7f819f1c5` | 6 | + +The three "was" SHAs are unreachable from `guarzo/zoo` but remain reachable from +`origin/guarzo/zoo-prerewrite`, so `git show defb24439` still works after a fresh clone as +long as that ref exists. Do not delete it. + +Verified before the push: the final tree is byte-identical to the pre-rewrite tip +(`git diff --stat` empty), all 61 downstream commits retain identical trees and subjects, all +3 merge commits survive with 2 parents each, and each of the 18 new commits builds under both +`yarn build` and `mix compile` so `git bisect` stays usable across the region. + +What this does **not** fix: `git blame` still dead-ends at the split commits rather than at +real authorship, because the underlying rationale was never recorded. `docs/ZOO-FORK.md` +remains the map into that region. The split buys navigability and bisectability, not history. + +One caveat on the push itself โ€” it bypassed the branch ruleset ("Test Suite" required check, +"changes must be made through a pull request"), which is inherent to a force-push. The +rewritten history has therefore never run CI as a unit. + +### Minor note + +`e69d727bd` ("temporary failing test to validate the deploy gate on a red commit") is a +deliberately-red commit on the mainline, reverted cleanly by `151031c90` โ€” verified as an +empty diff across the pair. It is a small `git bisect` landmine and it is documented in +`docs/superpowers/plans/2026-08-07-deploy-approval-gate.md`. Worth a sentence in the merge +policy saying not to do this again; not worth fixing retroactively. + +--- + +## Prompt 15 โ€” Prune merged branches + +> **โœ… MOSTLY DONE โ€” 2026-08-08.** Remote branches 176 โ†’ 130, local 94 โ†’ 76. Only 4 merged +> remote refs remain. The three backup branches were **deliberately kept** โ€” see the +> correction below; they are the one part of this prompt that should not be carried out as +> written. Re-derive the counts before acting, as the prompt already instructs. + +### The finding + +At review time, counting only `refs/remotes/origin`: **176 remote branches, 94 local**, of +which **63 remote refs are already fully merged into `guarzo/zoo`**. + +โš  Re-derive these numbers yourself with the commands below rather than trusting them. An +earlier draft of this prompt said 85 merged remotes; that figure came from a bare +`git branch -r --merged`, which also sweeps in `upstream/*` refs that must never be deleted +from `origin`. The 63 above excludes them. Treat any count you did not produce as stale. + +The backup branches (`backup/guarzo-zoo-prerebase`, `zoo-backup-prerebase`, +`backup-pre-rebase-102`) are **not** being kept deliberately โ€” they simply have not been +cleaned up yet. + +โš  Corrected 2026-08-08: an earlier draft said they "can go". Checked before deleting, and +each one holds commits reachable from **no** surviving ref โ€” 18, 38, and 32 respectively. +That is expected for pre-rebase snapshots, whose commits were rewritten rather than merged, +but it means deleting them discards the only copy rather than removing a duplicate. They are +local-only refs costing nothing, and they have been left in place. If you do delete them, +do it as a deliberate choice, not as cleanup. Run the containment check first: + +```bash +for b in backup/guarzo-zoo-prerebase zoo-backup-prerebase backup-pre-rebase-102; do + printf '%-32s unique-commits=%s\n' "$b" \ + "$(git rev-list --count "$b" --not origin/guarzo/zoo origin/guarzo/zoo-prerewrite)" +done +``` + +The same check applies to any branch in the list below: `--merged` proves containment, but +these backups are precisely the refs that are *not* merged anywhere. + +### The task + +Deleting branches is destructive and easy to get wrong, so work in this order and **show the +list before deleting anything**. + +**1. Refresh, then build the candidate lists.** + +Enumerate only refs under `refs/remotes/origin` โ€” a bare `git branch -r --merged` also returns +`upstream/*` refs, and `git push origin --delete upstream/foo` is nonsense at best. Strip the +prefix at the same time, so the names are already in the form `push --delete` wants: + +```bash +git fetch origin --prune +git fetch upstream --prune + +# remote candidates: bare branch name + object ID, origin only +git for-each-ref --merged origin/guarzo/zoo \ + --format='%(refname:strip=3) %(objectname)' refs/remotes/origin > /tmp/remote-candidates.txt + +# local candidates +git for-each-ref --merged origin/guarzo/zoo \ + --format='%(refname:strip=2) %(objectname)' refs/heads > /tmp/local-candidates.txt +``` + +**2. Apply exclusions โ€” after normalization, so the names match.** + +Drop from both lists regardless of merge status: `main`, `develop`, `HEAD`, `guarzo/zoo`, +anything checked out in a worktree, and anything with an open PR: + +```bash +# branches with an open PR, machine-readable +gh pr list --state open --limit 500 --json headRefName --jq '.[].headRefName' | sort -u > /tmp/keep-pr.txt + +# branches checked out in some worktree +git worktree list --porcelain | awk '/^branch /{sub("refs/heads/","",$2); print $2}' | sort -u > /tmp/keep-wt.txt + +printf '%s\n' main develop HEAD guarzo/zoo >> /tmp/keep-pr.txt +cat /tmp/keep-pr.txt /tmp/keep-wt.txt | sort -u > /tmp/keep.txt + +awk 'NR==FNR{k[$1];next} !($1 in k)' /tmp/keep.txt /tmp/remote-candidates.txt > /tmp/remote-delete.txt +awk 'NR==FNR{k[$1];next} !($1 in k)' /tmp/keep.txt /tmp/local-candidates.txt > /tmp/local-delete.txt +``` + +**3. Show the human the filtered lists and the counts.** `wc -l` both files and display them. +**Get explicit confirmation before deleting.** Do not proceed on inferred approval. + +**4. Delete locals first** โ€” they are recoverable from reflog: + +```bash +cut -d' ' -f1 /tmp/local-delete.txt | xargs -r -n1 git branch -d # -d, never -D +``` + +`-d` refuses to delete anything not actually merged, so it is a second safety net. If it +refuses a branch, investigate rather than reaching for `-D`. + +**5. Then remotes, in small batches** so a mistake is containable: + +```bash +cut -d' ' -f1 /tmp/remote-delete.txt | head -20 | xargs -r git push origin --delete +``` + +**6. Report the before/after counts.** + +### Also worth doing + +`git worktree list` โ€” prune worktrees whose branches are gone (`git worktree prune`). + +โš  **Do not prune the worktree you are running in**, and do not delete its branch. Run worktree +cleanup from a separate administrative checkout, and for each target worktree confirm it is +both clean (`git -C status --porcelain` empty) and inactive (no session working in it) +before removing it. A worktree whose branch you just deleted becomes a detached, confusing +mess for whoever is sitting in it. + +### Recovery note + +Deleted remote branches are **not** in your local reflog. `/tmp/remote-delete.txt` from step 2 +is your restore map โ€” it holds the exact filtered set plus each branch's object ID: + +```bash +# restore one +git push origin :refs/heads/ +``` + +Keep that file until the human confirms nothing is missing. + +--- + +## Prompt 16 โ€” Settings-tab feature-flag bypass (upstream) + +> **New 2026-08-08.** Found by re-reviewing the 7 commits that landed after this document +> was written (#129). Fixed on the fork in `c6a78c12` (#131); the weakness is still present +> upstream. + +### The finding + +`handle_event("change_settings_tab", %{"tab" => tab}, socket)` assigns whatever tab string +the client pushes: + +```elixir +def handle_event("change_settings_tab", %{"tab" => tab}, socket), + do: {:noreply, socket |> assign(active_settings_tab: tab)} +``` + +Verified present verbatim at `lib/wanderer_app_web/live/maps/maps_live.ex:399-400` on **both** +`upstream/main` and `upstream/develop`. + +The tab list in `maps_live.html.heex` guards three tabs on `@map_subscriptions_enabled?` +(lines 289, 316, 398) and one on `not WandererApp.Env.public_api_disabled?()` (line 370). +Because the handler does not re-check, those `:if` guards are cosmetic โ€” a crafted event +renders the Balance, Subscription or Bots panel with the flag off. Public Api was the only +tab with a defence-in-depth body re-check. + +### Scope it honestly + +This is **feature-flag bypass, not privilege escalation.** The settings dialog is already +gated on the `delete_map` permission in `apply_action(:settings, ...)`, so every actor +reaching this handler is a map owner or ACL admin. Say that plainly in the PR โ€” overstating +it as a security hole will cost credibility, and a maintainer will check. + +### The fix + +Take `c6a78c12` as the reference. It adds an allowlist mirroring the template guards and +keeps the current selection when a tab is unknown or its flag is off. Confirm the module +attributes and the `:if` guards stay in sync โ€” that coupling is the fix's weak point and +deserves the comment the fork version carries. + +The commit also replaces a bare `"general"` literal with `@default_settings_tab`. Keep that; +it is small and it is what makes the allowlist readable. + +### Verify before opening + +```bash +git show upstream/main:lib/wanderer_app_web/live/maps/maps_live.ex | grep -A2 change_settings_tab +``` + +If upstream has since added a guard, stop โ€” the finding is closed. + +--- + +## Prompt 17 โ€” `button/1` style variants (upstream, low priority) + +> **New 2026-08-08.** Fixed on the fork in `9a0ce094` (#132). + +### The finding + +`core_components.ex` `button/1` on `upstream/main` has no variant attribute โ€” every caller +hand-writes classes onto a single outlined style (`upstream/main:...core_components.ex:292`). +Confirmed: no `attr :variant` exists upstream. + +The fork added `primary` / `secondary` / `ghost` / `danger`. + +### Why it is low priority + +This is an enhancement, not a bug, and it touches a file every LiveView imports โ€” a maintainer +may reasonably have opinions about the variant names or prefer their own design tokens. Open +it as a discussion or a draft PR rather than a finished one, and be ready for the names to +change. + +--- + +## Triage of the remaining post-#129 commits + +Assessed and deliberately **not** upstreamable โ€” every path they touch is fork-only: + +| Commit | What | Why not upstream | +|--------|------|------------------| +| `1d3d4032` (#130) | route-alerts checkbox toggle | `map_notifications_component.ex` is fork-only | +| `f394497a` (#134) | route-alert home system by name | same file, fork-only | +| `4f70afdc` (#133) | Discord channel identity + collisions | Discord subsystem is fork-only | +| `b04677d6` (#137) | Notifications tab restructure | same file, fork-only | +| `2e304d54` (#135) | maps_v1 scopes drift + codegen CI gate | the migration and snapshot are fork-only; the `test.yml` part overlaps prompt 5 | + +`2e304d54` is the only borderline case: its `test.yml` change could ride along with prompt 5, +but the migration it accompanies is fork-specific, so do not bundle them. + +--- + +## Suggested order + +If you are running several sessions at once: + +**Start immediately, no conflicts, high value:** 1, 3, 4, 5, 6, 16 +**Start immediately, lower value:** 8, 9, 17 +**Sequence:** 2 โ†’ 7 (shared file) +**Do last, needs a decision first:** 10 (may become a discussion issue rather than a PR) + +Prompts 11, 12, 13 and 15 are done โ€” see the markers on each. 14 is superseded in part by +the history rewrite; the merge-policy half still stands. diff --git a/docs/superpowers/plans/2026-08-04-flyio-migration.md b/docs/superpowers/plans/2026-08-04-flyio-migration.md new file mode 100644 index 000000000..5e1c612e1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-flyio-migration.md @@ -0,0 +1,1689 @@ +# Fly.io Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move a self-hosted Wanderer instance and its hard dependency wanderer-kills off a single docker-compose VM onto Fly.io, with Postgres on Fly Managed Postgres. + +**Architecture:** Two single-machine Fly apps in `iad` โ€” `wanderer` (public, TLS at the Fly edge) and `wanderer-kills` (no public IP, reachable only over the private 6PN network). Both apps are pinned to exactly one machine because their state is node-local Cachex plus a node-local Registry, so a second machine would silently serve half the map. Postgres is Fly Managed Postgres on the same private network. + +**Tech Stack:** Elixir 1.17.3-otp-26, Phoenix, Ash Framework, PostgreSQL >= 15, Fly.io (`flyctl`), `phoenix_gen_socket_client` 4.0.0, `websocket_client` 1.5.0. + +**Source spec:** `docs/superpowers/specs/2026-08-02-flyio-migration-design.md` + +## Global Constraints + +- **Exactly one machine per app.** `auto_stop_machines = 'off'`, `min_machines_running = 1`, no autoscaling, `DNS_CLUSTER_QUERY` unset. Non-negotiable for both apps โ€” see the spec's "Exactly one machine, for both apps". +- **Region `iad`** for both apps and for MPG. +- **PostgreSQL >= 15** (`WandererApp.Repo.min_pg_version/0`, `lib/wanderer_app/repo.ex:11-13`). +- **All code changes must be backward-compatible with non-Fly deployments.** Every new environment variable defaults to the current behaviour. These files are upstream-shared and the diff must stay rebase-friendly. +- **Machine liveness must never depend on a product feature flag.** The health route may not sit behind any pipeline that can halt on configuration. +- **No behavioural change to the application** beyond what these tasks specify. +- **`docs/` is listed in `/app/.git/info/exclude`.** That is a local convenience so scratch files stay out of `git status`; it does not mean `docs/` is untracked โ€” `docs/ZOO-FORK.md` is committed. The spec and this plan have been force-added and are now tracked on this branch, so edits to them show up in `git status` normally and must be committed like any other file. Do not edit the exclude file. Code changes under `lib/`, `config/`, `test/`, and `fly.toml` are unaffected. + +## Decision Log + +Resolved during planning, recorded here because they shape task ordering: + +- **6PN reachability: pursue Option A, keep Option B as a fallback.** Task 5 submits the upstream bind-address change; Task 7 is a go/no-go checkpoint that picks Task 8A (direct 6PN) or Task 8B (Flycast). The cutover date does not depend on someone else merging a pull request. +- Tasks 1-4 are pure code in this repository and can proceed immediately, in parallel with the upstream pull request. +- Tasks 9-13 are an operator runbook, not TDD. They are marked as such. + +--- + +## File Structure + +**This repository (`wanderer`):** + +| File | Responsibility | Task | +|---|---|---| +| `lib/wanderer_app/helpers/config.ex` | Add two pure resolver functions for host and external URL. Pure so they are unit-testable; `config/runtime.exs` itself is not. | 1 | +| `test/unit/config_helpers_test.exs` | New. Covers both resolvers, including the backward-compatibility matrix. | 1 | +| `config/runtime.exs:16-38` | Call the resolvers instead of inlining the `case` expressions. | 1 | +| `lib/wanderer_app/kills/transport/web_socket_client.ex` | New. Thin transport shim that forwards `:socket_opts` to `:websocket_client`. Sole responsibility: widen the option split. | 2 | +| `test/unit/kills/transport/web_socket_client_test.exs` | New. Covers the option split. | 2 | +| `lib/wanderer_app/kills/client.ex:486-503` | Point at the shim; pass `socket_opts: [:inet6]` when configured. | 2 | +| `lib/wanderer_app/env.ex` | Add `wanderer_kills_ipv6?/0`. | 2 | +| `config/runtime.exs` (kills block) | Read `WANDERER_KILLS_IPV6`, default `"false"`. | 2 | +| `mix.exs:80` | Pin `phoenix_gen_socket_client` to `== 4.0.0` โ€” the shim couples to a private contract. | 2 | +| `lib/wanderer_app_web/controllers/health_controller.ex` | New. Liveness, always 200 while the app is serving; database state reported in the body, never in the status code. No dependencies on product configuration. | 3 | +| `lib/wanderer_app_web/router.ex` | New `:health` pipeline and route. | 3 | +| `test/wanderer_app_web/controllers/health_controller_test.exs` | New. Includes the regression test that the route survives `public_api_disabled`. | 3 | +| `fly.toml` | Production-shaped rewrite. | 4 | + +Nothing is added to this repository's `docs/` or `README.md`. The deployment +guide goes to the self-hosting repository instead โ€” see Task 14. + +**Upstream repository (`wanderer-industries/wanderer-kills`), separate clone:** + +| File | Responsibility | Task | +|---|---|---| +| `config/runtime.exs` | Configurable bind address, defaulting to today's `{0, 0, 0, 0}`. | 5 | +| `fly.toml` | New. Operator-agnostic, one machine, port 4004, `/health` check. | 8A / 8B | + +**Self-hosting repository (`wanderer-industries/community-edition`), separate clone:** + +| File | Responsibility | Task | +|---|---|---| +| `fly-io/README.md` | New. Operator-facing guide: fresh Fly install and the docker-compose migration path. | 14 | +| `fly-io/fly.toml` | New. Wanderer app template, operator-agnostic. | 14 | +| `fly-io/fly-kills.toml` | New. wanderer-kills app template, operator-agnostic. | 14 | +| `README.md` | One link to `fly-io/`, alongside the existing `reverse-proxy/` and `scripts/` links. | 14 | + +--- + +## Task 1: Host and external-URL resolution (blocking change 1) + +**Why this is blocking:** on Fly, `FLY_APP_NAME` is always set, so `config/runtime.exs:19-21` forces `host` to `.fly.dev` and `:36-38` forces `web_app_url` to `https://`. Both `PHX_HOST` and `WEB_APP_URL` are read **only** on the `app_name == "NOT_FLY_APP"` branch, so on Fly neither environment variable is read at all. That propagates into the EVE OAuth `callback_url` at `config/runtime.exs:268`, which means neither the staging subdomain nor the production hostname can complete a login. The fix turns the `.fly.dev` derivation from an override into a fallback. + +**Files:** +- Modify: `lib/wanderer_app/helpers/config.ex` +- Modify: `config/runtime.exs:16-38` +- Test: `test/unit/config_helpers_test.exs` (create) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `WandererApp.ConfigHelpers.resolve_host(phx_host :: String.t() | nil, fly_app_name :: String.t() | nil) :: String.t()` + - `WandererApp.ConfigHelpers.resolve_web_app_url(web_app_url :: String.t() | nil, host :: String.t(), port :: integer(), fly_app_name :: String.t() | nil) :: String.t()` + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/config_helpers_test.exs`: + +```elixir +defmodule WandererApp.ConfigHelpersTest do + # Pure functions: no app env, no cache, no process state. + use ExUnit.Case, async: true + + alias WandererApp.ConfigHelpers + + # "NOT_FLY_APP" is the sentinel `runtime.exs` uses as the default for + # FLY_APP_NAME, so it must be treated as "not on Fly", not as an app name. + describe "resolve_host/2 off Fly" do + test "uses PHX_HOST when set" do + assert ConfigHelpers.resolve_host("map.example.com", "NOT_FLY_APP") == + "map.example.com" + end + + test "falls back to localhost when PHX_HOST is unset" do + assert ConfigHelpers.resolve_host(nil, "NOT_FLY_APP") == "localhost" + assert ConfigHelpers.resolve_host("", "NOT_FLY_APP") == "localhost" + end + + test "treats a missing FLY_APP_NAME the same as the sentinel" do + assert ConfigHelpers.resolve_host(nil, nil) == "localhost" + assert ConfigHelpers.resolve_host("map.example.com", nil) == "map.example.com" + end + end + + describe "resolve_host/2 on Fly" do + test "derives from FLY_APP_NAME when PHX_HOST is unset" do + assert ConfigHelpers.resolve_host(nil, "wanderer") == "wanderer.fly.dev" + assert ConfigHelpers.resolve_host("", "wanderer") == "wanderer.fly.dev" + end + + # This is the whole point of the change: on Fly, an explicitly-set + # PHX_HOST must win, otherwise a custom domain is unreachable. + test "prefers an explicitly-set PHX_HOST over the .fly.dev derivation" do + assert ConfigHelpers.resolve_host("map.example.com", "wanderer") == + "map.example.com" + end + end + + describe "resolve_web_app_url/4 off Fly" do + test "uses WEB_APP_URL when set" do + assert ConfigHelpers.resolve_web_app_url( + "https://map.example.com", + "localhost", + 8000, + "NOT_FLY_APP" + ) == "https://map.example.com" + end + + test "falls back to http://host:port when WEB_APP_URL is unset" do + assert ConfigHelpers.resolve_web_app_url(nil, "localhost", 8000, "NOT_FLY_APP") == + "http://localhost:8000" + end + + test "passes an explicitly-empty WEB_APP_URL through so the scheme check still raises" do + # `WEB_APP_URL=` in a .env file yields "" rather than nil. Today that reaches + # URI.parse/1, produces a nil scheme, and raises at boot with the variable named. + # Treating "" as unset would replace that loud failure with a silently wrong + # OAuth callback URL, so "" must pass through unchanged. + assert ConfigHelpers.resolve_web_app_url("", "localhost", 8000, "NOT_FLY_APP") == "" + end + end + + describe "resolve_web_app_url/4 on Fly" do + test "derives https from the resolved host when WEB_APP_URL is unset" do + assert ConfigHelpers.resolve_web_app_url(nil, "wanderer.fly.dev", 8080, "wanderer") == + "https://wanderer.fly.dev" + end + + test "prefers an explicitly-set WEB_APP_URL over the https derivation" do + assert ConfigHelpers.resolve_web_app_url( + "https://map.example.com", + "map.example.com", + 8080, + "wanderer" + ) == "https://map.example.com" + end + + # Composition check: the two resolvers must agree, because the EVE OAuth + # callback_url (runtime.exs:268) is built from web_app_url. + test "composes with resolve_host so a custom domain flows into the URL" do + host = ConfigHelpers.resolve_host("map.example.com", "wanderer") + assert ConfigHelpers.resolve_web_app_url(nil, host, 8080, "wanderer") == + "https://map.example.com" + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/config_helpers_test.exs` +Expected: FAIL with `function WandererApp.ConfigHelpers.resolve_host/2 is undefined or private`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `lib/wanderer_app/helpers/config.ex`, inside the module: + +```elixir + @fly_sentinel "NOT_FLY_APP" + + @doc """ + Resolves the external hostname. + + `FLY_APP_NAME` is a **fallback**, not an override. An operator who sets + `PHX_HOST` explicitly gets it even on Fly, which is what makes a custom + domain โ€” and therefore a working EVE OAuth callback โ€” possible. When + `PHX_HOST` is unset the behaviour is unchanged from before this function + existed. + + An explicitly-empty `PHX_HOST` is treated as unset. Before this function + existed it produced `http://:8000`, which is not a usable URL for anyone; + `localhost` is the same value an unset variable gives. Contrast + `resolve_web_app_url/4`, where an empty string must pass through so the + caller's scheme check still raises. + """ + def resolve_host(phx_host, fly_app_name) + + def resolve_host(phx_host, _fly_app_name) when is_binary(phx_host) and phx_host != "", + do: phx_host + + def resolve_host(_phx_host, fly_app_name) + when is_binary(fly_app_name) and fly_app_name != "" and fly_app_name != @fly_sentinel, + do: "#{fly_app_name}.fly.dev" + + def resolve_host(_phx_host, _fly_app_name), do: "localhost" + + @doc """ + Resolves the externally-visible base URL. + + Same rule as `resolve_host/2`: an explicit `WEB_APP_URL` always wins. On Fly + without one, https is assumed because the Fly edge terminates TLS. + + Note the first clause matches **any** binary, including `""`. That is + deliberate and differs from `resolve_host/2`. `WEB_APP_URL=` in a `.env` file + yields `""`, not nil, and the caller parses the result and raises when the + scheme is missing. Treating `""` as unset here would swap that named, + at-boot error for an app that starts with a silently wrong OAuth callback. + """ + def resolve_web_app_url(web_app_url, host, port, fly_app_name) + + def resolve_web_app_url(web_app_url, _host, _port, _fly_app_name) + when is_binary(web_app_url), + do: web_app_url + + def resolve_web_app_url(_web_app_url, host, _port, fly_app_name) + when is_binary(fly_app_name) and fly_app_name != "" and fly_app_name != @fly_sentinel, + do: "https://#{host}" + + def resolve_web_app_url(_web_app_url, host, port, _fly_app_name), + do: "http://#{host}:#{port}" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/config_helpers_test.exs` +Expected: PASS, 10 tests. + +- [ ] **Step 5: Wire the resolvers into `config/runtime.exs`** + +Replace `config/runtime.exs:18-22` (the `host = case ... end` expression) with: + +```elixir +host = resolve_host(System.get_env("PHX_HOST"), app_name) +``` + +Replace `config/runtime.exs:34-38` (the `web_app_url = case ... end` expression) with: + +```elixir +web_app_url = + resolve_web_app_url(System.get_env("WEB_APP_URL"), host, web_port, app_name) +``` + +Leave line 16 (`app_name = System.get_env("FLY_APP_NAME", "NOT_FLY_APP")`) and the `web_port` block between them exactly as they are. `WandererApp.ConfigHelpers` is already imported at `config/runtime.exs:2`, so no new import is needed. + +- [ ] **Step 6: Verify the config file still evaluates** + +Run: `mix compile --force` +Expected: compiles clean. A `runtime.exs` syntax or arity error surfaces here. + +Then confirm the non-Fly default is genuinely unchanged. `--no-start` evaluates `config/runtime.exs` without booting the supervision tree, so this needs no database: + +Run: `mix run --no-start -e 'IO.inspect(Application.get_env(:wanderer_app, :web_app_url))'` +Expected: `"http://localhost:8000"` โ€” the same value as before the change. + +Then confirm the new override path works, which is the whole point of the task: + +Run: `FLY_APP_NAME=wanderer PHX_HOST=map.example.com mix run --no-start -e 'IO.inspect(Application.get_env(:wanderer_app, :web_app_url))'` +Expected: `"https://map.example.com"`. Before this change it would have been `"https://wanderer.fly.dev"`. + +- [ ] **Step 7: Run the full unit suite** + +Run: `mix test test/unit` +Expected: PASS, no new failures. + +- [ ] **Step 8: Format and commit** + +```bash +mix format lib/wanderer_app/helpers/config.ex test/unit/config_helpers_test.exs config/runtime.exs +git add lib/wanderer_app/helpers/config.ex test/unit/config_helpers_test.exs config/runtime.exs +git commit -m "fix(config): let PHX_HOST and WEB_APP_URL override the .fly.dev derivation + +On Fly, FLY_APP_NAME is always set, so both env vars sat on the dead branch of +their case expressions and were never read. That forced the EVE OAuth +callback_url to .fly.dev, making a custom domain impossible. + +Behaviour is unchanged when neither variable is set." +``` + +--- + +## Task 2: Websocket transport that forwards socket options (blocking change 2) + +**Why this is blocking:** the kills websocket cannot reach a Fly private address without it. Traced through three files: + +1. `lib/wanderer_app/kills/client.ex:486-498` passes `transport_opts: [timeout:, tcp_opts: [...]]`. +2. `deps/phoenix_gen_socket_client/lib/gen_socket_client/transport/web_socket_client.ex:18` defines `@websocket_client_opts [:extra_headers, :ssl_verify]`; line 30 splits on **exactly those two keys** and line 34 passes everything else as the *handler state*, not as socket options. +3. `deps/websocket_client/src/websocket_client.erl:195` reads `socket_opts` to build the transport โ€” precisely the key filtered out at step 2. + +Two consequences. First, the existing `connect_timeout` / `send_timeout` / `recv_timeout` values are **already dead config today**; this is a pre-existing latent bug independent of Fly. Second, there is no supported path to pass `:inet6`, and Erlang's `gen_tcp` defaults to IPv4 for hostname resolution, so `ws://wanderer-kills.internal:4004` would fail to resolve. + +`GenSocketClient` passes `opts[:transport_opts]` verbatim to `transport_mod.start_link/2` (`gen_socket_client.ex:247` and `:385`), and the `Transport` behaviour has exactly two callbacks, `start_link/2` and `push/2` (`gen_socket_client/transport.ex:18,22`). A shim is therefore complete at ~15 lines. + +**What this does *not* fix โ€” read before touching the timeouts.** Only `:socket_opts` reaches `:websocket_client`. The `timeout` and `tcp_opts` keys in `client.ex` remain handler state, and upstream's `init/1` reads exactly one key from it, `:keepalive` (`web_socket_client.ex:48-51`). Worse, `websocket_client` 1.5.0 **hardcodes its connect timeout to 6000 ms** (`websocket_client.erl:276`, `(T#transport.mod):connect(Host, Port, T#transport.opts, 6000)`), so there is no option that would change it. `connect_timeout`, `send_timeout`, and `recv_timeout` are dead config before this change and dead config after it. Step 6 deletes them rather than leaving knobs that look adjustable and are not. The one option that genuinely reaches the handler is `:keepalive`, defaulting to 30s. + +**Files:** +- Create: `lib/wanderer_app/kills/transport/web_socket_client.ex` +- Modify: `lib/wanderer_app/kills/client.ex:486-503` +- Modify: `lib/wanderer_app/env.ex` (add `wanderer_kills_ipv6?/0` beside `wanderer_kills_service_enabled?/0` at line 38) +- Modify: `config/runtime.exs` (kills block at lines 67-74; config assignment at lines 191-192) +- Modify: `mix.exs:80` +- Test: `test/unit/kills/transport/web_socket_client_test.exs` (create) + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: + - `WandererApp.Kills.Transport.WebSocketClient.split_opts(Keyword.t()) :: {Keyword.t(), Keyword.t()}` + - `WandererApp.Kills.Transport.WebSocketClient.start_link(String.t(), Keyword.t()) :: {:ok, pid()} | {:error, term()}` + - `WandererApp.Env.wanderer_kills_ipv6?() :: boolean()` + - Application env key `:wanderer_kills_ipv6` + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/kills/transport/web_socket_client_test.exs`: + +```elixir +defmodule WandererApp.Kills.Transport.WebSocketClientTest do + # `split_opts/1` is pure. + use ExUnit.Case, async: true + + alias WandererApp.Kills.Transport.WebSocketClient + + describe "split_opts/1" do + # The bug this module exists to fix: upstream splits on + # [:extra_headers, :ssl_verify] only, so :socket_opts fell through into the + # handler-state argument and never reached :websocket_client. + test "routes :socket_opts to the websocket_client options" do + {ws_opts, rest} = WebSocketClient.split_opts(socket_opts: [:inet6]) + + assert ws_opts == [socket_opts: [:inet6]] + assert rest == [] + end + + test "still routes the two options upstream already handled" do + {ws_opts, rest} = + WebSocketClient.split_opts(extra_headers: [{"x", "y"}], ssl_verify: :verify_none) + + assert Keyword.fetch!(ws_opts, :extra_headers) == [{"x", "y"}] + assert Keyword.fetch!(ws_opts, :ssl_verify) == :verify_none + assert rest == [] + end + + # Anything upstream treats as handler state must keep being handler state, + # or the shim breaks GenSocketClient rather than fixing it. + test "leaves unrecognised options in the handler-state half" do + {ws_opts, rest} = WebSocketClient.split_opts(timeout: 10_000, tcp_opts: [x: 1]) + + assert ws_opts == [] + assert Keyword.fetch!(rest, :timeout) == 10_000 + assert Keyword.fetch!(rest, :tcp_opts) == [x: 1] + end + + test "partitions a mixed keyword list into both halves" do + {ws_opts, rest} = + WebSocketClient.split_opts(socket_opts: [:inet6], timeout: 10_000) + + assert ws_opts == [socket_opts: [:inet6]] + assert rest == [timeout: 10_000] + end + + test "handles an empty option list" do + assert WebSocketClient.split_opts([]) == {[], []} + end + end + + describe "behaviour conformance" do + # The shim delegates push/2 and implements start_link/2. If a + # phoenix_gen_socket_client upgrade adds a callback, this test fails and + # the handler-state coupling gets re-verified โ€” which is the point. + test "exports both Transport callbacks" do + assert function_exported?(WebSocketClient, :start_link, 2) + assert function_exported?(WebSocketClient, :push, 2) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/kills/transport/web_socket_client_test.exs` +Expected: FAIL with `module WandererApp.Kills.Transport.WebSocketClient is not available`. + +- [ ] **Step 3: Write minimal implementation** + +Create `lib/wanderer_app/kills/transport/web_socket_client.ex`: + +```elixir +defmodule WandererApp.Kills.Transport.WebSocketClient do + @moduledoc """ + A thin wrapper around `Phoenix.Channels.GenSocketClient.Transport.WebSocketClient` + that also forwards `:socket_opts` through to `:websocket_client`. + + Upstream splits transport options on exactly `[:extra_headers, :ssl_verify]` + (`web_socket_client.ex:18`) and passes everything else through as the handler + state, so `:socket_opts` โ€” the key `:websocket_client` actually reads + (`websocket_client.erl:195`) โ€” never reaches the socket. + + Without this, `:inet6` cannot be set, and Fly's 6PN `.internal` addresses are + IPv6-only while Erlang's `gen_tcp` resolves hostnames as IPv4 by default. + + This module couples to an upstream private contract: the handler-state + argument is `[socket, transport_options]` (`web_socket_client.ex:48`). + `phoenix_gen_socket_client` is pinned in `mix.exs` for that reason. Delete + this module once `:socket_opts` is added to upstream's split list. + """ + @behaviour Phoenix.Channels.GenSocketClient.Transport + + @upstream Phoenix.Channels.GenSocketClient.Transport.WebSocketClient + @ws_opts [:extra_headers, :ssl_verify, :socket_opts] + + @doc """ + Partitions transport options into `{websocket_client_options, handler_state}`. + + Public only so it can be tested directly; not part of the behaviour. + """ + def split_opts(transport_options), do: Keyword.split(transport_options, @ws_opts) + + @impl true + def start_link(url, transport_options) do + {ws_opts, rest} = split_opts(transport_options) + + url + |> to_charlist() + |> :websocket_client.start_link(@upstream, [self(), rest], ws_opts) + end + + @impl true + defdelegate push(pid, frame), to: @upstream +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/kills/transport/web_socket_client_test.exs` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Add the `WANDERER_KILLS_IPV6` setting** + +In `config/runtime.exs`, immediately after the `wanderer_kills_base_url` block (currently lines 72-74), add: + +```elixir +# Fly's 6PN `.internal` and `.flycast` addresses are IPv6-only, and gen_tcp +# resolves hostnames as IPv4 by default. Mirrors the ECTO_IPV6 precedent below. +wanderer_kills_ipv6 = + config_dir + |> get_var_from_path_or_env("WANDERER_KILLS_IPV6", "false") + |> String.to_existing_atom() +``` + +In the `config :wanderer_app, ...` block, immediately after `wanderer_kills_base_url: wanderer_kills_base_url,` (currently line 192), add: + +```elixir + wanderer_kills_ipv6: wanderer_kills_ipv6, +``` + +In `lib/wanderer_app/env.ex`, immediately after the `wanderer_kills_service_enabled?/0` definition (line 38), add: + +```elixir + def wanderer_kills_ipv6?(), do: get_key(:wanderer_kills_ipv6, false) +``` + +- [ ] **Step 6: Point the client at the shim** + +In `lib/wanderer_app/kills/client.ex`, replace the `opts = [...]` block (currently lines 486-498) with: + +```elixir + # GenSocketClient passes :transport_opts verbatim to the transport's + # start_link/2 (gen_socket_client.ex:247, :385). + # + # :socket_opts reaches the socket only via + # WandererApp.Kills.Transport.WebSocketClient โ€” upstream's transport filters + # it out. + # + # The connect/send/recv timeouts that used to sit here were removed: they + # never applied. Upstream's handler init/1 reads only :keepalive, and + # websocket_client 1.5.0 hardcodes its connect timeout to 6000ms + # (websocket_client.erl:276). They were adjustable-looking and inert. + socket_opts = if WandererApp.Env.wanderer_kills_ipv6?(), do: [:inet6], else: [] + + opts = [transport_opts: [socket_opts: socket_opts]] +``` + +Then, in the `GenSocketClient.start_link(...)` call immediately below, replace the transport module argument: + +```elixir + case GenSocketClient.start_link( + __MODULE__.Handler, + WandererApp.Kills.Transport.WebSocketClient, + handler_state, + opts + ) do +``` + +- [ ] **Step 7: Pin the coupled dependency** + +In `mix.exs`, change line 80 from `{:phoenix_gen_socket_client, "~> 4.0"},` to: + +```elixir + # Pinned: WandererApp.Kills.Transport.WebSocketClient depends on this + # library's private handler-state shape. Re-verify that shim before + # bumping. + {:phoenix_gen_socket_client, "== 4.0.0"}, +``` + +Run: `mix deps.get` +Expected: no change to `mix.lock` โ€” 4.0.0 is already locked. + +- [ ] **Step 8: Confirm the removed timeouts really were dead** + +Do not take the plan's word for it โ€” the whole reason this task exists is that an option silently failed to reach its destination. Verify in the checkout: + +Run: `grep -n 'keepalive\|transport_options' deps/phoenix_gen_socket_client/lib/gen_socket_client/transport/web_socket_client.ex` +Expected: `init/1` reads `:keepalive` and nothing else from `transport_options`. + +Run: `grep -n 'connect(Host, Port' deps/websocket_client/src/websocket_client.erl` +Expected: the literal `6000` as the fourth argument โ€” a hardcoded connect timeout with no option behind it. + +If either has changed in a newer version, the removal in Step 6 needs revisiting. + +- [ ] **Step 9: Run the kills tests and compile with warnings as errors** + +Run: `mix test test/unit/kills` +Expected: PASS, no new failures. + +Run: `mix compile --force --warnings-as-errors` +Expected: no warnings. A `@impl true` on a non-callback, or an unused variable in the new client block, surfaces here. + +- [ ] **Step 10: Format and commit** + +```bash +mix format lib/wanderer_app/kills/transport/web_socket_client.ex \ + test/unit/kills/transport/web_socket_client_test.exs \ + lib/wanderer_app/kills/client.ex lib/wanderer_app/env.ex config/runtime.exs mix.exs +git add lib/wanderer_app/kills/transport/web_socket_client.ex \ + test/unit/kills/transport/web_socket_client_test.exs \ + lib/wanderer_app/kills/client.ex lib/wanderer_app/env.ex config/runtime.exs mix.exs +git commit -m "fix(kills): forward socket options to the websocket transport + +phoenix_gen_socket_client splits transport options on [:extra_headers, +:ssl_verify] only, so :socket_opts fell through into the handler-state argument +and never reached :websocket_client. That made :inet6 unsettable, and Fly's 6PN +.internal addresses are IPv6-only. + +Also removes the connect/send/recv timeouts from client.ex. They never applied: +upstream's handler init/1 reads only :keepalive, and websocket_client 1.5.0 +hardcodes its connect timeout to 6000ms. They looked adjustable and were not. + +Gated on WANDERER_KILLS_IPV6, default false, so non-Fly deployments are +unaffected. phoenix_gen_socket_client pinned because the shim depends on its +private handler-state shape." +``` + +--- + +## Task 3: Health endpoint on a dedicated pipeline (non-blocking change 4) + +**Why the pipeline matters:** the obvious home is the empty "Health Check Endpoints" scope at `lib/wanderer_app_web/router.ex:374-379`, but that scope is `pipe_through [:api]`, and the `:api` pipeline (`router.ex:171-175`) includes `WandererAppWeb.Plugs.CheckApiDisabled`, which halts with `403` when `WandererApp.Env.public_api_disabled?/0` is true. With `min_machines_running = 1` and no redundancy, chaining machine liveness to a product feature flag means `WANDERER_PUBLIC_API_DISABLED=true` would make Fly kill the **sole** machine โ€” a config toggle becoming a total outage. + +The failure is latent, not immediate: `WANDERER_PUBLIC_API_DISABLED` defaults to `"false"` (`config/runtime.exs:57-59`), so it would work on day one and break catastrophically much later. That is the worse shape of bug, which is why the regression test in Step 1 is the important part of this task. + +**Files:** +- Create: `lib/wanderer_app_web/controllers/health_controller.ex` +- Modify: `lib/wanderer_app_web/router.ex` (new pipeline near line 171; new scope near line 374) +- Test: `test/wanderer_app_web/controllers/health_controller_test.exs` (create) + +**Interfaces:** +- Consumes: `WandererApp.Env.vsn/0` (`lib/wanderer_app/env.ex:14`). +- Produces: `GET /health` returning `200` with `%{"status" => "ok", "version" => String.t(), "database" => "ok" | "unreachable"}`. + +**Why the database does not gate the status code.** An earlier draft returned +`503` when Postgres was unreachable. Under `min_machines_running = 1` that hands +Fly a kill signal for a fault Fly cannot repair: restarting the app does nothing +about an external Postgres outage, so a transient blip โ€” a partition, pool +exhaustion, a slow migration โ€” buys a window with zero machines serving traffic +while the database problem continues. The check answers "is this machine +serving?", which it is. Database state is still reported, in the body, where a +human or a dashboard can read it without it being wired to a restart. + +- [ ] **Step 1: Write the failing test** + +Create `test/wanderer_app_web/controllers/health_controller_test.exs`: + +```elixir +defmodule WandererAppWeb.HealthControllerTest do + use WandererAppWeb.ConnCase + + import WandererApp.EnvHelper + + test "GET /health returns 200 with status, version and database state", %{conn: conn} do + conn = get(conn, "/health") + + assert %{"status" => "ok", "version" => version, "database" => database} = + json_response(conn, 200) + + assert is_binary(version) + assert database == "ok" + end + + # The reason this route does not live in the :api scope. Fly kills an + # unhealthy machine, and there is exactly one, so a 403 here is a total + # outage triggered by a product feature flag. This test is the guard. + test "GET /health still returns 200 when the public API is disabled", %{conn: conn} do + with_env_override(:public_api_disabled, true) do + conn = get(conn, "/health") + assert %{"status" => "ok"} = json_response(conn, 200) + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/wanderer_app_web/controllers/health_controller_test.exs` +Expected: FAIL. Not with `Phoenix.Router.NoRouteError` โ€” `GET /health` currently matches the `live "/:slug", MapLive, :index` wildcard and returns a 302 to `/welcome`, so the failure is `json_response/2` receiving a 302 where it expected 200. + +- [ ] **Step 3: Write minimal implementation** + +Create `lib/wanderer_app_web/controllers/health_controller.ex`: + +```elixir +defmodule WandererAppWeb.HealthController do + @moduledoc """ + Machine liveness for Fly health checks. + + Answers one question: is this machine serving? It must never gain an + authentication, rate-limiting, or feature-flag plug โ€” see the `:health` + pipeline in the router. + + Database reachability is reported in the body but deliberately does not change + the status code. Fly kills a machine that fails its check, and under + `min_machines_running = 1` that is the only machine; a restart cannot repair an + external Postgres outage, so letting the database drive the status code would + turn a transient blip into a self-inflicted outage. + """ + use WandererAppWeb, :controller + + # Short on purpose. This endpoint is polled every few seconds; the Repo default + # of 15s would let a saturated database hold each request open long enough for + # polls to pile up on top of the problem. + @db_check_timeout_ms 2_000 + + def index(conn, _params) do + json(conn, %{ + status: "ok", + version: to_string(WandererApp.Env.vsn()), + database: if(database_reachable?(), do: "ok", else: "unreachable") + }) + end + + defp database_reachable? do + case Ecto.Adapters.SQL.query(WandererApp.Repo, "SELECT 1", [], + timeout: @db_check_timeout_ms + ) do + {:ok, _} -> true + _ -> false + end + rescue + # Most query-level failures come back as an error tuple and are handled + # above; this catches the ones that raise instead. + _ -> false + catch + # `rescue` does not cover exits. If the connection pool is not alive, the + # GenServer.call inside DBConnection exits with :noproc or :timeout, which + # would otherwise crash the request into a 500 โ€” the exact status this + # endpoint exists to avoid returning for a database fault. + :exit, _ -> false + end +end +``` + +- [ ] **Step 4: Add the pipeline and route** + +In `lib/wanderer_app_web/router.ex`, immediately after the `pipeline :api do ... end` block (currently ending at line 175), add: + +```elixir + # Deliberately minimal. Fly kills a machine that fails its health check and + # there is exactly one machine, so nothing that can be switched off by + # configuration may appear here โ€” no CheckApiDisabled, no auth, no rate limit. + pipeline :health do + plug :accepts, ["json"] + end +``` + +Then replace the empty scope at lines 374-379 with: + +```elixir + # + # Health Check Endpoints + # Used for monitoring, load balancer health checks, and deployment validation + # + # This scope's POSITION IN THE FILE IS LOAD-BEARING. It must stay above the + # `live "/:slug", MapLive, :index` wildcard further down. Phoenix matches + # routes in definition order, so below that line `/health` is swallowed by the + # wildcard and answers 302 to /welcome instead of 200. + scope "/", WandererAppWeb do + pipe_through [:health] + + get "/health", HealthController, :index + end +``` + +Note this drops the `/api` prefix along with the `:api` pipeline. + +`/health` **does** collide with the root LiveView wildcard `live "/:slug", MapLive, :index`, which currently serves `GET /health` as a 302 to `/welcome`. Phoenix matches routes in definition order โ€” there is no rule preferring literal segments over dynamic ones โ€” so this new scope wins only because it is defined earlier in the file. That is why the comment above is there. The two existing tests do double duty as the regression guard: both assert a 200, so either would fail if someone moved this scope below the wildcard. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `mix test test/wanderer_app_web/controllers/health_controller_test.exs` +Expected: PASS, 2 tests. + +- [ ] **Step 6: Confirm the route is where you think it is** + +Run: `mix phx.routes | grep -i health` +Expected: one line, `GET /health WandererAppWeb.HealthController :index`. + +- [ ] **Step 7: Run the web suite** + +Run: `mix test test/wanderer_app_web` +Expected: PASS, no new failures. + +- [ ] **Step 8: Format and commit** + +```bash +mix format lib/wanderer_app_web/controllers/health_controller.ex \ + lib/wanderer_app_web/router.ex \ + test/wanderer_app_web/controllers/health_controller_test.exs +git add lib/wanderer_app_web/controllers/health_controller.ex \ + lib/wanderer_app_web/router.ex \ + test/wanderer_app_web/controllers/health_controller_test.exs +git commit -m "feat(web): add GET /health on a dedicated pipeline + +Returns app version and database reachability, for Fly health checks. + +Deliberately not in the :api scope: that pipeline includes CheckApiDisabled, +which halts 403 when WANDERER_PUBLIC_API_DISABLED is set. With +min_machines_running = 1, an unhealthy check kills the only machine, so a +feature flag would become a total outage. The flag defaults to false, so that +failure would have been latent." +``` + +--- + +## Task 4: Production-shaped `fly.toml` for wanderer (non-blocking changes 5 and 6) + +The existing file is a 2024 scaffold for `wanderer-test` in `ams`. Four problems: wrong app and region, `min_machines_running = 0` (which contradicts the single-machine constraint from the other direction โ€” zero machines means a cold map), a hard-coded `PHX_HOST` that Task 1 has now made load-bearing, and a `[[metrics]]` block scraping `:4021/metrics` when `PROMEX_DISABLED` defaults to `"true"` (`config/runtime.exs:457-459`), so every scrape fails. + +**Files:** +- Modify: `fly.toml` (full rewrite) + +**Interfaces:** +- Consumes: `GET /health` from Task 3; the `PHX_HOST` / `WEB_APP_URL` precedence from Task 1. +- Produces: the deployable wanderer app configuration. + +- [ ] **Step 1: Replace `fly.toml` entirely** + +Substitute the operator's real app name for ``: + +```toml +# fly.toml โ€” wanderer +# +# EXACTLY ONE MACHINE. This is an architectural constraint, not a preference. +# Map state lives in node-local Cachex tables and character trackers register in +# a node-local Registry (WandererApp.Character.TrackerRegistry); PubSub uses the +# PG2 adapter with no clustering configured. Two machines would produce two +# independent halves of the same map โ€” trackers updating on one node, LiveView +# sessions subscribed on the other, and updates that never meet. +# +# Do not add autoscaling, do not raise min/max machines, and do not set +# DNS_CLUSTER_QUERY without first making map state cluster-aware. + +app = '' +primary_region = 'iad' +kill_signal = 'SIGTERM' +swap_size_mb = 512 + +[build] + +[deploy] + release_command = '/app/bin/migrate.sh' + +[env] + PHX_SERVER = 'true' + PORT = '8080' + # PHX_HOST and WEB_APP_URL are set as secrets, not here: they change at + # cutover when the staging subdomain is replaced by the production hostname. + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'off' + auto_start_machines = false + min_machines_running = 1 + processes = ['app'] + + [http_service.concurrency] + type = 'connections' + hard_limit = 1000 + soft_limit = 1000 + + [[http_service.checks]] + grace_period = '30s' + interval = '15s' + method = 'GET' + path = '/health' + protocol = 'http' + timeout = '5s' + +[[vm]] + size = 'shared-cpu-2x' + memory = '2gb' +``` + +Note what was removed and why: `PHX_HOST = 'wanderer-test.fly.dev'` (Task 1 made it an override, so leaving it hard-coded would defeat the whole change), and the `[[metrics]]` block (nothing listens on `:4021`; metrics are out of scope and the block is four lines to restore). + +The `grace_period` of `30s` is set against the spec's stated 30-60s restart gap. If boot regularly exceeds it, Fly will kill the machine mid-boot in a loop โ€” raise it rather than lowering the check interval. + +- [ ] **Step 2: Validate the file** + +Run: `fly config validate --config fly.toml` +Expected: `Configuration is valid`. + +If `flyctl` rejects `[[http_service.checks]]` or the `grace_period` format, check current Fly documentation rather than assuming this file is right โ€” the schema has changed before. + +- [ ] **Step 3: Commit** + +```bash +git add fly.toml +git commit -m "chore(fly): production-shaped fly.toml + +Real app name, iad, shared-cpu-2x/2gb, min_machines_running = 1 with the +single-machine rationale in a comment, /health check. + +Drops the hard-coded PHX_HOST, which would defeat the custom-domain change, and +the [[metrics]] block, which scrapes :4021 while PROMEX_DISABLED defaults true +so every scrape fails." +``` + +- [ ] **Step 4: Open the pull request for Tasks 1-4** + +These four changes are all upstream-suitable โ€” none is zoo-specific โ€” so target the upstream default branch, not `guarzo/zoo`. + +```bash +git push -u origin HEAD +gh pr create --repo guarzo/wanderer --fill +``` + +Remember that a bare `gh pr` command resolves to `wanderer-industries`, not `guarzo`. Always pass `--repo`. + +--- + +## Task 5: Configurable bind address, upstream wanderer-kills (blocking change 3, Option A) + +**Why this is blocking:** the client-side `:inet6` fix from Task 2 is necessary but **not sufficient**. Upstream `config/config.exs` sets `http: [port: 4004, ip: {0, 0, 0, 0}]`, and upstream `config/runtime.exs` overrides only the port โ€” there is no `ip:` key and no bind-address environment variable anywhere in it. Fly's 6PN addresses are IPv6 and Fly requires the server to listen on the 6PN address (or on `::`). Without this change, Task 2's fix would connect to an address with no listener. + +Submit this early โ€” Task 7 is a checkpoint on whether it landed, and Task 8B exists so the cutover date does not depend on the answer. + +**Files (in a separate clone, not this repository):** +- Modify: `config/runtime.exs` in `wanderer-industries/wanderer-kills` + +**Interfaces:** +- Produces: environment variable `BIND_IP`, default `"0.0.0.0"`, consumed by Task 8A. + +- [ ] **Step 1: Clone your fork of the upstream repository** + +You have read access to `wanderer-industries/wanderer-kills`, so this is a fork-and-pull-request. A fork already exists at `guarzo/wanderer-kills`; sync it before branching, since it may be behind. + +```bash +git clone git@github.com:guarzo/wanderer-kills.git /tmp/wanderer-kills +git -C /tmp/wanderer-kills remote add upstream https://github.com/wanderer-industries/wanderer-kills.git +git -C /tmp/wanderer-kills fetch upstream +git -C /tmp/wanderer-kills checkout -b feat/configurable-bind-address upstream/main +``` + +Branching from `upstream/main` rather than the fork's `main` sidesteps a stale fork entirely โ€” you never have to decide whether it needed syncing. + +If the upstream default branch is not `main`, use whatever `git -C /tmp/wanderer-kills remote show upstream` reports as HEAD. + +- [ ] **Step 2: Read the current endpoint configuration before changing it** + +Read `config/config.exs` and `config/runtime.exs` in the clone. Confirm the `http: [port: 4004, ip: {0, 0, 0, 0}]` line and the `PORT`-only override. If either has changed since this plan was written, adapt โ€” the repository is the territory. + +- [ ] **Step 3: Add the bind-address override** + +In the clone's `config/runtime.exs`, alongside the existing `PORT` handling, add: + +```elixir +# Bind address. Defaults to 0.0.0.0 so existing docker-compose deployments are +# unaffected. Set to "::" on platforms whose private networking is IPv6-only โ€” +# Fly.io's 6PN, for instance, where a 0.0.0.0 listener is unreachable from +# sibling apps. +bind_ip = + System.get_env("BIND_IP", "0.0.0.0") + |> String.to_charlist() + |> :inet.parse_address() + |> case do + {:ok, address} -> address + {:error, _} -> raise "BIND_IP must be a valid IP address. Got: #{System.get_env("BIND_IP")}" + end +``` + +Then add `ip: bind_ip` to the endpoint's `http:` keyword list in the same file, alongside `port:`. + +- [ ] **Step 4: Verify both the default and the override** + +```bash +cd /tmp/wanderer-kills && mix deps.get && mix compile +``` + +Default path โ€” must still bind IPv4: + +```bash +cd /tmp/wanderer-kills && MIX_ENV=prod mix run --no-start -e ' + IO.inspect(Application.get_env(:wanderer_kills, WandererKillsWeb.Endpoint)[:http])' +``` +Expected: includes `ip: {0, 0, 0, 0}`. + +Override path: + +```bash +cd /tmp/wanderer-kills && BIND_IP="::" MIX_ENV=prod mix run --no-start -e ' + IO.inspect(Application.get_env(:wanderer_kills, WandererKillsWeb.Endpoint)[:http])' +``` +Expected: includes `ip: {0, 0, 0, 0, 0, 0, 0, 0}`. + +Adjust the endpoint module name and OTP app atom to match what the clone actually uses. + +Invalid path: + +```bash +cd /tmp/wanderer-kills && BIND_IP="nonsense" MIX_ENV=prod mix run --no-start -e ':ok' +``` +Expected: raises with the `BIND_IP must be a valid IP address` message. Failing loudly at boot beats silently binding IPv4 and leaving a 6PN address unreachable. + +- [ ] **Step 5: Add a test if the repository has a config test convention** + +Check for existing tests covering `runtime.exs` behaviour. If there is a pattern, follow it. If `runtime.exs` is untested there โ€” likely, since it is not compiled โ€” the Step 4 checks are the verification, and say so in the pull request body. + +- [ ] **Step 6: Commit and open the pull request** + +```bash +cd /tmp/wanderer-kills +git add config/runtime.exs +git commit -m "feat(config): make the HTTP bind address configurable via BIND_IP + +Defaults to 0.0.0.0, so existing deployments are unaffected. + +Platforms with IPv6-only private networking โ€” Fly.io's 6PN, for instance โ€” +require the listener to be on the private address or on ::, and a 0.0.0.0 +listener is simply unreachable from sibling apps there. There is currently no +way to configure that without patching config/config.exs." +git push -u origin feat/configurable-bind-address +gh pr create --repo wanderer-industries/wanderer-kills \ + --head guarzo:feat/configurable-bind-address --fill +``` + +`origin` is your fork, so the push does not need upstream write access. `--repo` names the target and `--head :` names the source; without both, `gh` guesses from the remote configuration and can open the pull request against your own fork instead. + +In the pull request body, note the deployment motivation and confirm the variable name with the maintainer โ€” `BIND_IP` is this plan's proposal, not an established convention in that repository. + +--- + +## Task 6: Provision the Fly apps and measure (Phase 0) + +**Operator runbook, not TDD.** No user impact; nothing here is destructive to the running VM. + +- [ ] **Step 1: Measure the database** + +```bash +psql "$VM_DATABASE_URL" -c "SELECT pg_size_pretty(pg_database_size(current_database()));" +``` + +- [ ] **Step 2: Time a rehearsal dump *and restore*** + +```bash +time pg_dump -Fc "$VM_DATABASE_URL" -f /tmp/wanderer-rehearsal.dump +``` + +**Then time an actual restore into MPG, with the exact flags Task 11 step 5 uses.** Do this after step 4 has provisioned MPG โ€” reorder these steps if necessary. The restore is usually the larger half of the window, and on managed Postgres over the network it can dominate: + +```bash +time pg_restore -d "$MPG_DATABASE_URL" --clean --if-exists --no-owner --no-privileges \ + /tmp/wanderer-rehearsal.dump +``` + +**The two timings together are the outage window.** Timing only the dump advertises a window that excludes most of the work. Record the sum; Task 11 step 3 announces it to users. + +This rehearsal restore also doubles as the staging restore in Task 10 step 1 โ€” the data is the same, so there is no reason to do it twice. + +- [ ] **Step 3: Measure the kills service's memory** + +```bash +docker stats --no-stream wanderer-kills +``` + +Sample over a representative period, not once. The 2 GB figure in the spec is a starting estimate, not a measurement: the service ingests EVE-wide killmails with a 24-hour TTL, and the resident set could plausibly range from a few hundred MB to over 1 GB. Under-sizing presents as OOM restarts. + +- [ ] **Step 4: Create both apps and the database** + +```bash +fly apps create +fly apps create +fly mpg create --region iad # PostgreSQL >= 15 +``` + +Confirm the MPG version is >= 15 before continuing โ€” `WandererApp.Repo.min_pg_version/0` requires it and a lower version fails at migration time, not at provision time. + +- [ ] **Step 5: Confirm the kills app has no public IP** + +```bash +fly ips list --app +``` +Expected: empty. If `fly apps create` allocated one, release it. The service is private-only; plain `ws://` is correct because 6PN is a WireGuard mesh encrypted at the network layer. + +- [ ] **Step 6: Set the wanderer secrets** + +```bash +fly secrets set --app \ + SECRET_KEY_BASE=... \ + EVE_CLIENT_ID=... \ + EVE_CLIENT_SECRET=... \ + WANDERER_ADMIN_PASSWORD=... \ + WEB_APP_URL=https:// \ + DATABASE_URL=... \ + ECTO_IPV6=true \ + WANDERER_KILLS_SERVICE_ENABLED=true \ + WANDERER_KILLS_BASE_URL=ws://.internal:4004 \ + WANDERER_KILLS_IPV6=true +``` + +Three of these are new or newly load-bearing: + +- `WANDERER_KILLS_SERVICE_ENABLED` **defaults to `"false"`** (`config/runtime.exs:67-70`). Omit it and neither `WandererApp.Kills.Supervisor` nor `WandererApp.Map.ZkbDataFetcher` starts at all (`lib/wanderer_app/application.ex:226-238`) โ€” kills are silently off with no error anywhere. +- `WANDERER_KILLS_BASE_URL` must be the `.internal` address under Option A, or `ws://.flycast:4004` under Option B. +- `WANDERER_KILLS_IPV6=true` activates Task 2's change. + +Add any additional EVE client pairs the VM uses. Do **not** blanket-copy the VM's remaining feature flags yet โ€” Task 10 audits them for outbound effects first. + +- [ ] **Step 7: Create the staging EVE application** + +**RESOLVED 2026-08-05: an EVE application permits exactly one redirect URL.** A +second EVE application is therefore *required* for the staging period, not merely +preferred โ€” staging cannot share production's application at any point. + +That also happens to be the safer arrangement, for the reason Task 10 step 2a +gives: a staging instance that refreshes a production character's token +invalidates it and logs real users out of the live map. Separate applications +means separate client IDs, so this cannot happen by accident. + +Create it now and record both credential pairs. Callback URLs, from +`config/config.exs:53` (`callback_path: "/auth/eve/callback"`): + +- staging application โ†’ `https:///auth/eve/callback` +- production application โ†’ `https:///auth/eve/callback`, which + is what it is already set to today + +Note what this means for the cutover: the production hostname does not change +when DNS moves to Fly, so the production application's redirect URL needs **no +edit at any point in the migration**. Task 11 step 6 swaps the client ID and +secret secrets on the Fly app; nothing in the EVE developer portal is touched. + +--- + +## Task 7: RESOLVED โ€” Option A, deploying from the fork + +**Decision gate, closed on 2026-08-05. No code.** This task no longer requires +a check; it records a decision already made. + +**Original gate:** whether Task 5's `BIND_IP` change had merged upstream in time +for the cutover window. If not, Option B (Flycast) avoided needing `BIND_IP` at +all. + +**What changed:** the kills app is deployed by building from a source checkout โ€” +Task 8A's `fly.toml` has an empty `[build]` section and deploys with +`fly deploy --config .../fly.toml`, so Fly builds the Dockerfile from the working +tree. Nothing pulls a published upstream image. The upstream merge was therefore +never on the critical path for *deploying*; it only determined whether we carry a +local patch. + +`guarzo/wanderer-kills` was already ahead of upstream (it carries the +Elixir 1.19.5 / OTP 28 dependency refresh, merged there as #6 and still pending +upstream as #8), so a fork-based deploy adds no new maintenance posture. +`feat/configurable-bind-address` was merged into that fork's `main` as `5756469` +โ€” a clean merge, verified conflict-free with `git merge-tree` beforehand. + +A third divergence landed on 2026-08-05: a fix for a nil telemetry measurement +that crash-loops the metrics GenServer and, past the supervisor's restart +intensity, shuts down the whole application tree. It is on the fork as PR #8 and +upstream as +[wanderer-industries/wanderer-kills#10](https://github.com/wanderer-industries/wanderer-kills/pull/10). +This one is a deploy prerequisite, not just hygiene โ€” the crash is reachable in +production from any sustained burst of reserved-token consumption against +zKillboard, which is exactly what a cold-start backfill produces. + +**Ruling: Option A. Deploy the kills app from `guarzo/wanderer-kills:main`.** +Task 8B is struck (see below). Upstream PR +[wanderer-industries/wanderer-kills#9](https://github.com/wanderer-industries/wanderer-kills/pull/9) +stays open; when it merges, this fork's divergence drops back toward zero and the +merge commit becomes a no-op. Nothing about the cutover date depends on it. + +**Consequence to carry into Task 9:** the deploy source is the fork, not a +clone of upstream. Clone `https://github.com/guarzo/wanderer-kills.git` and +deploy from its `main`. Merging upstream's changes into that fork periodically is +now an ongoing obligation โ€” the service ingests from zKillboard and ESI, so +upstream data-source fixes matter. + +**Verification debt:** the `BIND_IP` IPv6-bind evidence was gathered on +ranch 2.2.0 / cowboy 2.13.0 / plug_cowboy 2.7.4 / phoenix 1.7.21. The fork's +refresh moves these to ranch 2.2.1 / cowboy 2.18.0 / plug_cowboy 2.9.0 / +phoenix 1.8.9. See `.superpowers/sdd/2026-08-04-flyio-migration/fork-verify-report.md` +for the re-verification on the merged tree, and note what it does and does not +cover โ€” the CI container runs OTP 26 while the image builds on OTP 28. + +--- + +## Task 8A: `fly.toml` for wanderer-kills โ€” direct 6PN (Option A only) + +Task 7 chose Option A, so this task runs. Task 8B is struck. + +**Deploy source is the fork, not upstream.** Clone +`https://github.com/guarzo/wanderer-kills.git` and work from its `main`, which +carries `BIND_IP` as merge `5756469`. + +Keep the file **operator-agnostic** anyway: no hard-coded app name, `app` supplied +at deploy time. The fork is a staging post, not the destination โ€” this file should +stay upstreamable so it can go to `wanderer-industries` alongside PR #9 rather +than becoming fork-only drift. + +**Files:** +- Create: `fly.toml` in the fork clone (paths below say `/tmp/wanderer-kills`; + that clone is now on the fork's `main`) + +**Interfaces:** +- Consumes: `BIND_IP` from Task 5. +- Produces: an app reachable at `.internal:4004` over 6PN. + +- [ ] **Step 1: Create the file** + +```toml +# fly.toml โ€” wanderer-kills +# +# Deploy with: fly deploy --app +# The app name is deliberately not set here: this file is shared upstream. +# +# EXACTLY ONE MACHINE. The service's cache is node-local Cachex, so two machines +# would serve different answers depending on which one a subscription landed on. +# auto_stop_machines must stay off because a stopped machine drops the websocket +# its consumers hold open. +# +# No public IP. Reachable only over 6PN at .internal:4004. That is why +# BIND_IP is "::" โ€” a 0.0.0.0 listener cannot receive a 6PN connection. + +primary_region = 'iad' +kill_signal = 'SIGTERM' + +[build] + +[env] + PORT = '4004' + BIND_IP = '::' + +[[vm]] + size = 'shared-cpu-2x' + memory = '2gb' # starting figure โ€” see Task 6 step 3 + +# Top-level [checks], not [[services.*.checks]]: this app has no public service +# definition, and top-level checks do not require one. +[checks] + [checks.health] + type = 'http' + port = 4004 + path = '/health' + interval = '15s' + timeout = '5s' + grace_period = '30s' +``` + +Note there is no `[http_service]` and no `[[services]]` block โ€” that is what keeps the app off the public internet. + +- [ ] **Step 2: Verify the TOML against current Fly documentation** + +Run: `fly config validate --config /tmp/wanderer-kills/fly.toml --app ` +Expected: `Configuration is valid`. + +Fly's schema for top-level `[checks]` has changed before, and the `min_machines_running` / `auto_stop_machines` keys live under a service block, which this app does not have. Confirm the current mechanism for pinning a service-less app to one always-on machine โ€” it may be `fly scale count 1` plus `fly machine update --restart always` rather than a TOML key. **Do not assume this file is complete on the strength of this plan.** + +- [ ] **Step 3: Deploy and confirm it is private** + +```bash +fly deploy --config /tmp/wanderer-kills/fly.toml --app +fly ips list --app # expected: empty +fly status --app # expected: exactly one machine, started +``` + +- [ ] **Step 4: Confirm the IPv6 listener exists** + +This is the step that proves Task 5 worked. Do not skip it โ€” an IPv4-only listener is invisible until the websocket silently fails. + +```bash +fly ssh console --app -C "ss -ltnp" +``` +Expected: a listener on `[::]:4004` or on the machine's 6PN address, **not** `0.0.0.0:4004`. + +- [ ] **Step 5: Confirm 6PN reachability from the wanderer app** + +```bash +fly ssh console --app -C \ + "curl -sS -m 5 http://.internal:4004/health" +``` +Expected: a healthy response body. + +- [ ] **Step 6: Commit the file to your fork** + +```bash +cd /tmp/wanderer-kills +git add fly.toml +git commit -m "chore(fly): add an operator-agnostic fly.toml + +One machine (node-local Cachex cache), no public IP, 6PN-only on 4004, health +check against the existing /health endpoint. App name is supplied at deploy +time so the file is usable by any operator." +git push +``` + +Still `origin`, still your fork โ€” this is the same branch as Task 5, so `git push` with no arguments follows the upstream tracking set there. Fold it into the Task 5 pull request or open a second one from a fresh branch, as the maintainer prefers. + +If you deployed from this clone before committing, confirm you are not also pushing local experiments: `git -C /tmp/wanderer-kills status --short` should show nothing but the file you just committed. + +--- + +## Task 8B: STRUCK โ€” Flycast fallback (not needed) + +**Struck 2026-08-05. Do not execute.** This task existed only so the cutover +date would survive Task 5's `BIND_IP` change not landing upstream: Flycast routes +through the Fly proxy, which forwards to `internal_port` locally, so the +service's existing `{0, 0, 0, 0}` bind would have kept working without any +upstream change. + +Task 7 resolved to **Option A** โ€” the kills app deploys from +`guarzo/wanderer-kills:main`, which carries `BIND_IP` as merge `5756469`. The +contingency this task hedged against cannot occur, so building it would be dead +work: an extra proxy hop and a `[[services]]` block that Option A does not need. + +The original steps remain in git history if Option B is ever revived (for +example, if the fork is abandoned in favour of an unpatched upstream image before +PR #9 merges). Retrieve them with: + +```bash +git log --oneline -- docs/superpowers/plans/2026-08-04-flyio-migration.md +git show :docs/superpowers/plans/2026-08-04-flyio-migration.md +``` + +Note if you do revive it: the client-side `:inet6` change from Task 2 is required +under **both** options โ€” the Flycast address is IPv6 too. +## Task 9: Deploy and warm the kills app (Phase 1, first half) + +**Operator runbook, not TDD.** + +The dependency is one-directional โ€” wanderer subscribes to kills and tells it which systems to watch; kills never calls back. So this app can be deployed and left running to warm its cache **hours before** the cutover window opens, because nothing consumes it until wanderer points at it. The "same window" constraint applies only to the traffic switch, not to the deploy. + +- [ ] **Step 1: Confirm the app is running and healthy** + +```bash +fly status --app +fly logs --app +``` +Expected: exactly one machine, started, `/health` passing. + +- [ ] **Step 2: Leave it running** + +Give it time to ingest from zKillboard and ESI. It is stateless and rebuilds from those sources on boot; there is no database to provision and nothing to dump or restore. + +- [ ] **Step 3: Watch memory against the Task 6 measurement** + +```bash +fly machine status --app +``` + +If it approaches the 2 GB allocation, `fly scale vm` now rather than during the cutover window. OOM restarts do not take wanderer down โ€” the client degrades rather than crashing โ€” but they do drop kill data. + +--- + +## Task 10: Staging validation with staging-safe configuration (Phase 1, second half) + +**Operator runbook, not TDD.** This is the highest-risk task in the plan and the only one whose mistakes reach real users irreversibly. + +Staging runs a copy of production data **while production is still live**. That data carries live credentials and live outbound integrations, so an unmodified staging instance reaches into production's world. **Configure everything in Step 2 before the first boot against restored data, not after.** + +This is one Fly app throughout, not two: it first serves the staging subdomain, then has the production hostname added and the staging one removed. The single kills app serves both periods โ€” being stateless and private, it needs no staging duplicate. + +- [ ] **Step 1: Restore a throwaway copy of production data into MPG** + +```bash +pg_dump -Fc "$VM_DATABASE_URL" -f /tmp/wanderer-staging.dump +pg_restore -d "$MPG_DATABASE_URL" --no-owner --no-privileges /tmp/wanderer-staging.dump +``` + +`WandererApp.Repo.installed_extensions/0` returns only `["ash-functions"]` and no migration issues `CREATE EXTENSION` (`lib/wanderer_app/repo.ex:5-8`), so there is no native-extension risk here. + +This copy is for validation only and is discarded at cutover. + +- [ ] **Step 2: Apply the staging-safe configuration โ€” mandatory** + +**a. Use the separate staging EVE application.** This is the sharpest edge in the whole migration. `refresh_token/1` (`lib/wanderer_app/esi/api_client.ex:746`) reads a character's refresh token and persists the rotated result back via `WandererApp.Api.Character.update`. Because EVE rotates refresh tokens, a staging instance refreshing a character that production also tracks **invalidates production's token and logs real users out of the live map**. Set the staging EVE client ID and secret created in Task 6 step 7, and do not track production characters from staging. + +**b. Disable the outbound dispatchers.** `lib/wanderer_app/external_events/` contains `webhook_dispatcher.ex` and `discord_dispatcher.ex`, and the restored dump carries live destination rows. Left enabled, staging duplicates every notification your users receive. Turn the external events services off via configuration. + +**c. Scrub the destination rows in the restored copy.** Belt and braces โ€” the blast radius is other people's Discord servers, so configuration alone is not enough: + +```sql +DELETE FROM map_webhook_subscriptions_v1; +DELETE FROM map_discord_webhooks_v1; +DELETE FROM map_discord_notifications_v1; +``` + +The table names are versioned and plural โ€” they come from the `postgres do table(...) end` blocks in `lib/wanderer_app/api/map_webhook_subscription.ex:16`, `map_discord_webhook.ex:33`, and `map_discord_notification.ex:16`, which do not match the resource module names. Verify them against those files before running, because a typo here fails loudly *without* having removed anything, leaving you believing staging is scrubbed when it is not. + +Run this against the **MPG copy**, never against the VM database. Confirm the connection string before pressing enter. + +**d. Audit the remaining feature flags before copying them.** "Copy whatever the VM sets" is not safe as a blanket instruction; check each for outbound effects. + +This is the one place the plan deliberately does not validate production behaviour faithfully. The tradeoff is accepted: a staging instance that mails real users is worse than one that proves slightly less. + +- [ ] **Step 3: Deploy wanderer and point the staging subdomain at Fly** + +**Pre-flight โ€” read before the first `fly deploy` of wanderer.** This is the +first time `release_command` runs, and two independent failure modes live in +that window. Both were found by review, not by deploying. + +*a. Secrets must exist before the first deploy, or the release step fails.* +`release_command = '/app/bin/migrate.sh'` runs in a separate temporary Machine. +`migrate.sh` calls `bin/wanderer_app eval`, which evaluates `config/runtime.exs`, +which raises without `SECRET_KEY_BASE` (`config/runtime.exs:409-414`). So +`SECRET_KEY_BASE` and `DATABASE_URL` must already be set with `fly secrets set` +before this command, not after. `release_command` also has a **default 5-minute +timeout** โ€” if the migration set is large, raise it explicitly rather than +discovering the cap mid-deploy. + +*b. If the app machine crashloops while migrations succeeded, suspect the release +script, not the app.* `release_command` runs `eval`, which passes **no** +distribution flags; the app machine starts with `--name`. On Fly, +`rel/env.sh.eex` builds `RELEASE_NODE` from the IPv6 `FLY_PRIVATE_IP`, so it +needs `-proto_dist inet6_tcp`. That flag was commented out in `ee15d90f9` and +restored (inside the Fly branch only) on this branch. If someone re-removes it, +the deploy gets **past** the release step and then crashloops at the health +check โ€” migrations green, app dead. Do not debug the migration; check +`rel/env.sh.eex` first. + +```bash +fly deploy --app +fly certs add --app +``` +Then create the DNS record and wait for the certificate to issue. + +- [ ] **Step 4: Run all eight verification gates** + +Every gate must pass here, and again on production data after cutover. + +1. **EVE OAuth round-trip** โ€” log in with a character and get redirected back. Most likely thing to break: it depends on Task 1, the `WEB_APP_URL` secret, and the EVE callback all agreeing. +2. **Map loads with real data** โ€” systems, connections, and signatures render from the restored dump. Validates dump/restore, not just connectivity. +3. **Character tracking writes** โ€” a tracked character's location updates. Exercises ESI egress from Fly, token refresh, tracker pools, and DB writes. **On staging this must use a dedicated test character**, registered against the staging EVE application and not tracked by production. Using a real user's character here rotates their refresh token and logs them out of the live map. +4. **Real-time updates arrive** โ€” a change appears without a refresh. Proves the PubSub โ†’ LiveView path survived. +5. **Kills websocket reaches `connected`** โ€” check `WandererApp.Kills.get_status/0` via `fly ssh console` and a remote IEx session. This is the gate that proves Tasks 2, 5, and 8; it is the single most likely thing to fail, and **it fails silently**. Confirm explicitly rather than inferring from the absence of errors: after `@max_retries 10` the client stops retrying and falls back to a 15-minute health-check cycle (`lib/wanderer_app/kills/client.ex:19-30`), so a broken link looks exactly like a quiet one. +6. **Killmails render in the map UI** โ€” kill data appears on a system with recent activity. Proves subscription, ingest, storage, and broadcast, not merely that a socket opened. +7. **Release migrations ran clean** โ€” `interweave_migrate` completed with no pending migrations. Check the release command output in `fly logs`. +8. **Restart survivability** โ€” `fly machine restart` on **both** apps, then confirm the map rehydrates from Postgres and the kills client reconnects. This is the deploy rehearsal; every deploy is a restart with a 30-60s user-visible gap. + +- [ ] **Step 5: Do not proceed until all eight pass** + +Gate 5 in particular. A cutover with a silently broken kills link presents to users as "kill data stopped working", with no error and no alarm. + +--- + +## Task 11: Production cutover (Phase 2) + +**Operator runbook, not TDD. Planned outage.** Ordered so that nothing writes to two databases at once. + +- [ ] **Step 1: Lower the DNS TTL and pre-provision the production certificate** + +At least a day ahead. Not inside the window. + +```bash +fly certs add --app +fly certs show --app +``` + +**Issue the certificate before any traffic is directed at Fly.** Fly supports this explicitly: use the DNS-01 challenge, adding the `_acme-challenge` CNAME that `fly certs show` prints, so the hostname can be validated while it still resolves to the VM. An HTTP-01 challenge would require the hostname to already point at Fly, which forces certificate issuance into the outage window โ€” where an ACME delay or a DNS propagation lag becomes downtime with no way forward and no clean way back. + +Gate on readiness before opening the window: + +```bash +fly certs check --app +``` +Expected: the certificate reports as Ready / issued. **Do not start the cutover until it does.** + +- [ ] **Step 2: Confirm the kills app is up and its cache warm** + +Before the window, not inside it. + +```bash +fly status --app +``` + +- [ ] **Step 3: Announce the window, then stop wanderer on the VM** + +```bash +docker compose stop wanderer +``` + +Writes cease here, which is what makes the dump consistent. **Leave Postgres and the VM's kills container running** โ€” the kills container is part of the rollback path. + +- [ ] **Step 4: Scale the Fly wanderer app to zero and confirm no machine is running** + +```bash +fly scale count 0 --app +fly status --app # expected: zero machines running +``` + +**Mandatory and easy to overlook.** After Task 10 the Fly app is *live* against MPG, and its tracker pools write character locations every 10-30s with no user interaction at all. Restoring into a database that still has an application attached risks `pg_restore` conflicts and, worse, silently interleaves staging-era background writes into the restored production data. + +The app stays stopped through steps 5 and 6. + +- [ ] **Step 5: Dump the live VM database and restore into MPG** + +```bash +pg_dump -Fc "$VM_DATABASE_URL" -f /tmp/wanderer-cutover.dump +pg_restore -d "$MPG_DATABASE_URL" --clean --if-exists --no-owner --no-privileges \ + /tmp/wanderer-cutover.dump +``` + +This replaces the staging copy. Confirm the target connection string before pressing enter โ€” `--clean` is destructive by design. + +- [ ] **Step 6: Swap staging configuration for production configuration** + +Still with the app stopped. The certificate was issued in step 1, so nothing here waits on ACME: + +```bash +fly certs check --app # must still be Ready +fly secrets set --app \ + WEB_APP_URL=https:// \ + EVE_CLIENT_ID= \ + EVE_CLIENT_SECRET= +``` + +Re-enable the outbound dispatchers disabled in Task 10 step 2b. **No change is needed in the EVE developer portal** โ€” the production application's redirect URL already points at the production hostname, which does not change when DNS moves to Fly. Staging used a separate application (Task 6 step 7), so its callback is irrelevant from here on. + +Walk Task 10 step 2 in reverse, item by item. Anything left in its staging state is a production defect โ€” notifications silently not sending is the likely shape. + +- [ ] **Step 7: Run the migrations against the restored database โ€” do not skip** + +**The restore in step 5 rolled MPG's schema back to whatever the VM was running.** Task 10's staging deploy migrated MPG forward; `pg_restore --clean` erased that. If the release being deployed is newer than the VM's schema โ€” which it is, since it carries Tasks 1-3 โ€” production would otherwise boot against an outdated schema. + +`fly scale count` does **not** run `[deploy].release_command`. Fly executes that only during a deploy. Scaling from zero to one starts the machine and nothing else, so the migration must be run explicitly. + +Preferred โ€” a one-off machine, so migrations are verifiable before anything starts serving: + +```bash +fly machine run --app \ + --command "/app/bin/migrate.sh" \ + --rm \ + +``` + +Take `` from `fly releases --app --image`, or from the image the Task 10 deploy produced. + +Acceptable alternative โ€” a controlled deploy, which runs `release_command` in a temporary machine and then starts the app. This merges step 7 and step 8 into one command, so read step 8's warning before running it: + +```bash +fly deploy --app +``` + +Either way, **confirm `interweave_migrate` completed with no pending migrations before continuing** (verification gate 7). Check the output directly; do not infer success from the absence of an error. + +Note this does not affect the rollback story. Migrations alter the MPG copy, not the VM's Postgres, which remains the rollback target and is untouched. + +- [ ] **Step 8: Start the Fly app โ€” THIS IS THE COMMIT POINT** + +```bash +fly scale count 1 --app +``` + +**The point of no return is here, not at the DNS switch.** Tracker pools write character locations every 10-30s from the moment the app boots, with no user interaction required, so the window in which rollback is lossless closes **within seconds of this command** โ€” before any user has logged in. After this, rolling back loses whatever was written since. + +Before running it, confirm steps 6 and 7 are both complete. + +- [ ] **Step 9: Flip DNS to Fly, then re-run all eight gates** + +Re-run the Task 10 step 4 gates against production data. Gate 3 now legitimately uses a real character; gates 5 and 6 are the ones that prove the kills link survived the configuration swap. + +- [ ] **Step 10: Stop the rest of the VM stack** + +Only after step 9 passes. Stop the VM's kills container and the remaining services, but **do not delete anything** โ€” see Task 12. + +wanderer-notifier stays on the VM, reachable over the public internet, and is migrated as separate work. + +--- + +## Task 12: Rollback procedure (reference โ€” execute only if needed) + +**Not a step to perform. Read before Task 11 so it is familiar under pressure.** + +While the VM's Postgres is still the newer copy โ€” that is, before Task 11 step 8 โ€” rollback is: + +```bash +docker compose start wanderer wanderer-kills +# then flip DNS back +``` + +**Restart both containers, not just wanderer.** The Fly kills app is private-only, so a VM-resident wanderer cannot reach it. The VM's own kills container must come back up too. This is why Task 11 step 3 leaves it running and Task 11 step 10 only stops it at the very end. + +After Task 11 step 8, rollback loses every write since that command. Treat step 8 as the commit point, not step 9. + +Note that Task 11 step 7's migrations are **not** part of the point of no return: they alter the MPG copy, not the VM's Postgres. Rolling back after migrating but before starting is still lossless. + +Keep the entire VM stack intact but stopped for roughly a week after cutover. Do not delete volumes. + +--- + +## Task 13: Post-cutover follow-up + +- [ ] **Step 1: Confirm the notifier still delivers** + +It stayed on the VM and reaches the new host over the public internet. + +- [ ] **Step 2: Watch the kills link for the first few days** + +Kills failure is silent and self-limiting: after `@max_retries 10` the client stops retrying and falls back to a 15-minute health-check cycle. A prolonged outage presents as "kill data quietly stopped", not as an error. Check `WandererApp.Kills.get_status/0` periodically. Proper monitoring is a follow-up, out of scope here. + +- [ ] **Step 3: Watch ESI rate limiting** + +Rate limiting is per-source-IP, the egress IP has changed, and now *two* services call ESI from the same Fly organisation โ€” the kills service being the heavier consumer. Not expected to matter at private-corp scale, but it is a changed variable. + +- [ ] **Step 4: Right-size the kills machine** + +Compare actual resident memory against the 2 GB allocation and adjust with `fly scale vm`. + +- [ ] **Step 5: Retire the VM** + +After roughly a week of clean operation. Caddy, the docker-compose wanderer and wanderer-kills containers, and the host Postgres all go. The `WEB_EXTERNAL_SCHEME` / `HTTPS_PORT` / `/certs/*` branch in `config/runtime.exs:429-443` becomes dead config on Fly but is left in place, as it is upstream-shared code. + +- [ ] **Step 6: If Option B was chosen, revisit Option A** + +Once the upstream `BIND_IP` pull request from Task 5 lands and releases, switching from Flycast to direct 6PN removes a proxy hop and the `[[services]]` block. Low priority, but it closes the loop. + +- [ ] **Step 7: Delete the transport shim when upstream fixes it** + +If `phoenix_gen_socket_client` adds `:socket_opts` to `@websocket_client_opts`, `WandererApp.Kills.Transport.WebSocketClient` can be deleted and `mix.exs` unpinned. Submit that one-word change upstream if it has not been already. + +--- + +## Task 14: Write the deployment guide so other operators can reproduce this + +**The guide does not live in this repository.** Self-hosting instructions live in `wanderer-industries/community-edition` โ€” this repo's `README.md:29` already sends self-hosters there, and that repo describes itself as "Example Docker Compose setup for hosting Wanderer Community Edition". A Fly.io guide checked in here would be a second, competing home for the same audience, and the readers who need it are precisely the ones who never clone this repo. + +That repo is organised as **one topic subdirectory per deployment concern, each with its own `README.md`** โ€” `reverse-proxy/`, `scripts/`, `advanced/` โ€” with `docker-compose.yml` and `wanderer-conf.env` at the root. Follow that convention exactly: a new `fly-io/` directory, its `README.md` as the guide, and the two `fly.toml` files beside it as copy-and-edit templates, the way `reverse-proxy/` ships working `nginx`, `apache2`, `caddy-gen`, and `traefik` configs rather than describing them. + +**Repository:** `wanderer-industries/community-edition` (default branch `main`). You have read access, so this is a fork-and-pull-request, not a direct push. A fork already exists at `guarzo/community-edition`. + +**Files (all in the community-edition clone):** +- Create: `fly-io/README.md` +- Create: `fly-io/fly.toml` +- Create: `fly-io/fly-kills.toml` +- Modify: `README.md` (one link, in the same list as the existing `reverse-proxy/` and `scripts/` entries) + +Nothing in the `wanderer` repository changes in this task. + +- [ ] **Step 1: Clone the fork and branch** + +```bash +cd /tmp +git clone git@github.com:guarzo/community-edition.git +cd community-edition +git remote add upstream https://github.com/wanderer-industries/community-edition.git +git fetch upstream +git checkout -b feat/fly-io-deployment upstream/main +mkdir fly-io +``` + +Read `reverse-proxy/README.md` and `scripts/README.md` first and match their register โ€” they are short, imperative, and assume a competent operator who has not read the source. This guide is longer because the migration path is longer, but the voice should not change. + +- [ ] **Step 2: Add the two `fly.toml` templates** + +Copy the final files from Task 4 (`fly.toml` โ†’ `fly-io/fly.toml`) and Task 8A or 8B, whichever the Task 7 checkpoint selected (kills `fly.toml` โ†’ `fly-io/fly-kills.toml`). Then make them operator-agnostic: + +- `app` becomes `wanderer` and `wanderer-kills`. +- `primary_region` gets a comment saying to pick a region near your users and near your database, not to copy this one. +- Every comment justifying a value stays. The single-machine comment in particular is the reason the file is shaped this way, and a reader who deletes it will later delete the constraint. + +Run: `grep -nE "wanderer-test|iad|ams" fly-io/fly.toml fly-io/fly-kills.toml` +Expected: region names appear only inside comments or as clearly-labelled examples; no `wanderer-test`. + +- [ ] **Step 3: Write `fly-io/README.md`** + +**Write this while Tasks 6-13 are fresh, not weeks later.** The value is in the details that only surface during a real run โ€” the actual outage timings, the `flyctl` commands that turned out to need different flags, the step that was ambiguous at 2am. Draft as you go; finalise here. + +Write it for someone who has never seen the `wanderer` source, has no access to your Fly organisation, and is not migrating from your VM. Strip every operator-specific value. + +Cover, in this order: + +1. **What you get** โ€” two Fly apps and managed Postgres, replacing a docker-compose VM. State up front that this is a **single-machine** deployment and why, because that is the constraint most likely to be "optimised" away by a reader who skims. Point at `lib/wanderer_app/character/tracker_registry.ex` and the Cachex map state rather than asserting it. +2. **Prerequisites** โ€” a Fly account, `flyctl`, a domain, EVE SSO application credentials, PostgreSQL >= 15. +3. **Environment variables** โ€” a table of every variable the deployment needs, its default, and what breaks if it is wrong. Give `WANDERER_KILLS_SERVICE_ENABLED` its own callout: it defaults to `false`, and omitting it disables kills **silently, with no error anywhere**. That is the single most likely way a reader's deployment ends up quietly missing a feature. +4. **The two `fly.toml` files** โ€” point at `fly.toml` and `fly-kills.toml` beside the README rather than pasting them inline, so there is one copy to keep correct. Explain what a reader must change in each (`app`, `primary_region`, memory) and what they must not (the single-machine settings). +5. **Private networking** โ€” 6PN versus Flycast, why plain `ws://` is correct (WireGuard encrypts at the network layer), and the `BIND_IP` requirement. Include the `ss -ltnp` check from Task 8A step 4; a reader who binds IPv4-only gets a silent failure and no way to diagnose it from the logs. +6. **Fresh install** โ€” the path for someone with no existing data. This is most readers, and it is much shorter: create apps, set secrets, deploy. It comes *before* the migration path for that reason. +7. **Migrating from docker-compose** โ€” Tasks 6 and 9-13 generalised. Keep the ordering rationale, not just the commands: why the app scales to zero before the restore, why migrations must be run explicitly because `fly scale` does not run `release_command`, and why the commit point is starting the app rather than flipping DNS. +8. **Staging safely against a copy of production data** โ€” Task 10 step 2 nearly verbatim. This is the section most likely to save a reader from harming their own users, particularly the EVE refresh-token rotation. Keep the reasoning; a bare checklist invites skipping. +9. **Verification** โ€” the eight gates. +10. **Rollback.** +11. **Known limitations** โ€” deploys are user-visible for 30-60s; kills failure is silent and self-limiting; single-machine means no HA. + +- [ ] **Step 4: Scrub it for operator-specific values** + +Run: `grep -nE '||fly\.dev|\.internal|\.flycast' fly-io/README.md` + +Every match must be a placeholder or a generic example. Then check by eye for anything that leaked from your own run: + +Run: `grep -rniE 'wanderer-test|[0-9]{1,3}(\.[0-9]{1,3}){3}' fly-io/` +Expected: no real hostnames, no real IPs, no real app names, no corporation names, no character names. This sweeps the two `fly.toml` files as well as the README โ€” the templates are the likelier place for a leak, since they are copied from a working deployment rather than written from scratch. + +This matters more here than it would in your own repository. `community-edition` is a public example repo that people copy verbatim; a leaked hostname becomes someone else's misconfiguration. + +- [ ] **Step 5: Have someone follow it who did not write it** + +The only real test of a deployment guide. A fresh install on a throwaway Fly app is enough โ€” the migration path cannot be rehearsed by a third party, so mark that section as reviewed-not-executed rather than implying it was tested. + +Every question they have to ask you is a defect in the document. Fix it rather than answering it. + +- [ ] **Step 6: Link it from the community-edition README** + +Add `fly-io/` to the same list that already points at `reverse-proxy/`, `scripts/`, and `advanced/`. Match the surrounding phrasing; read the existing entries before writing yours. + +One sentence of framing earns its place: this is an alternative to the docker-compose setup the rest of the repository documents, not an addition to it. Readers arriving at that repo are there for docker-compose, and a link with no context reads as a supplementary step rather than a fork in the road. + +- [ ] **Step 7: Commit and open the pull request** + +```bash +git add fly-io/README.md fly-io/fly.toml fly-io/fly-kills.toml README.md +git commit -m "docs: add a Fly.io deployment option + +Covers fresh installs and migration from docker-compose, templates for both +the wanderer and wanderer-kills apps, 6PN versus Flycast private networking, +and the staging-safety steps needed when validating against a copy of +production data. + +Written from an actual migration, so the outage timings and the failure modes +are measured rather than estimated." +git push -u origin feat/fly-io-deployment +gh pr create --repo wanderer-industries/community-edition \ + --title "docs: add a Fly.io deployment option" \ + --body "Adds fly-io/ alongside reverse-proxy/ and scripts/, following the same one-directory-per-topic layout. Written from a real migration off docker-compose, so the timings and failure modes are measured." +``` + +`--repo` is not optional. A bare `gh pr create` in a fork resolves to the upstream repository in some configurations and to the fork in others; state the target explicitly rather than depending on which. + +- [ ] **Step 8: Offer the wanderer-kills half upstream** + +The Fly-specific parts of the kills setup โ€” `BIND_IP`, the `fly.toml`, the 6PN and Flycast options โ€” are useful to any operator of that service, not just Wanderer users. Offer them as a deployment section in the `wanderer-industries/wanderer-kills` README, alongside the Task 5 pull request. + +Keep `fly-io/fly-kills.toml` in community-edition even if that lands. A Wanderer self-hoster should not have to visit a second repository to bring up a hard dependency, and the duplication is one small file. + +## Open items + +Both must be closed before the tasks that depend on them; neither blocks Tasks 1-4. + +- **EVE SSO callbacks** โ€” Task 6 step 7. Whether the developer portal allows multiple callback URLs per application determines whether a second EVE application is *required*. A second one is *wanted* regardless, for credential isolation during staging. +- **Kills memory sizing** โ€” Task 6 step 3. The 2 GB figure in Tasks 8A and 8B is a starting estimate, not a measurement. diff --git a/docs/superpowers/plans/2026-08-07-deploy-approval-gate.md b/docs/superpowers/plans/2026-08-07-deploy-approval-gate.md new file mode 100644 index 000000000..34f4d121d --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-deploy-approval-gate.md @@ -0,0 +1,827 @@ +# Deploy Approval Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Fly's branch-watch auto-deploy with a GitHub Actions workflow that deploys `guarzo/zoo` to Fly only after a green test suite and an explicit human approval, then tags the deployed commit. + +**Architecture:** One workflow (`.github/workflows/zoo-deploy.yml`) with a single gated job. It triggers on the `๐Ÿงช Test Suite` workflow succeeding on `guarzo/zoo`, waits on the `production-deploy` GitHub Environment for approval, verifies the commit is still current, runs `flyctl deploy`, and only then tags the SHA. The tag is the sole record of what is in production; `guarzo/release` is retired and frozen rather than kept as a bookmark. Fly's own GitHub integration is disconnected โ€” the workflow replaces it rather than running alongside it. + +**Tech Stack:** GitHub Actions, GitHub Environments (deployment protection rules), GitHub repository rulesets, Fly.io (`flyctl`), Docker build on Fly remote builders. + +**Spec:** `docs/superpowers/specs/2026-08-07-deploy-approval-gate-design.md` + +## Global Constraints + +- **Repository:** `guarzo/wanderer`. Default branch is `guarzo/zoo`, which is regularly rebased onto upstream and has commits squashed. +- **Fly app name:** `wanderer`. Never `wanderer-*`; the other Fly apps (`kills`, `route-builder`) are separate services and must not be touched. +- **Exactly one machine.** `fly.toml:1-11` documents this as an architectural constraint: map state lives in node-local Cachex tables and a node-local Registry. Do not add autoscaling, raise machine counts, or change `[deploy].strategy` from `rolling`. Every deploy is a full restart with a user-visible gap. +- **Tag format:** `v$(date +%Y%m%d%H%M%S)` โ€” matches the tags the deleted `release.yml` produced (e.g. `v20260805165448`). Do not switch to semver. +- **Deploy credential:** `FLY_DEPLOY_TOKEN` is an **environment** secret on `production-deploy`, never a repository secret. The name is deliberately not `FLY_API_TOKEN`: `advanced-test.yml` reads an ungated secret of that name for the separate `wanderer-test` app. The workflow maps it onto the `FLY_API_TOKEN` env var that `flyctl` itself reads. +- **Pin every third-party action to a full commit SHA** with the version in a trailing comment. This matches the deleted `release.yml` (`git show ce58765b^:.github/workflows/release.yml`), which pinned all four of its actions. It deliberately does **not** match `test.yml`, which uses floating version tags throughout (`test.yml:34`, `:87`, `:133`, `:210`, `:291`) โ€” this workflow holds a production deploy credential, so it takes the stricter posture rather than the local majority one. +- **Never cancel a running deploy.** `cancel-in-progress` must be `false` on the deploy job. +- **Do not modify** `.github/workflows/test.yml`, `build.yml`, `build-develop.yml`, `advanced-test.yml`, `release_actions.yml`, or `flaky-test-detection.yml`. The last four are upstream's. + +## A note on verification in this plan + +Most of this plan is CI configuration and GitHub/Fly settings, not application code, so there is no unit-test cycle to drive it. Verification is therefore **observation of real effects** โ€” API queries against GitHub, `flyctl` output, and one deliberate production deploy โ€” rather than assertions in a test file. Every task still ends with a concrete, checkable deliverable. Where a step's outcome cannot be verified without deploying, the plan says so instead of implying coverage it does not have. + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `.github/workflows/zoo-deploy.yml` | Create | The entire deploy pipeline: trigger, gate, staleness guard, deploy, tag | +| `docs/ZOO-FORK.md` | Modify | Document the deploy process so it stops being tribal knowledge | + +No application code changes. No migrations. + +## Task Sequence and Why It Is Ordered This Way + +1. **Task 1 โ€” operator prerequisites** (manual). Must precede everything: the workflow references an environment and a secret that must already exist, and Fly's integration must be disconnected before the workflow can deploy without double-deploying. +2. **Task 2 โ€” the workflow file**, merged to `guarzo/zoo`. Both `workflow_run` and `workflow_dispatch` only see workflow files on the default branch, so the file must be merged before any run is possible. +3. **Task 3 โ€” validation deploy** via `workflow_dispatch`. Proves the gate, the credential, the deploy, and the tag. +4. **Task 4 โ€” trigger validation** via a real push. Proves the part `workflow_dispatch` cannot exercise: that a deploy run appears only after a green suite. +5. **Task 5 โ€” documentation.** + +--- + +### Task 1: Operator prerequisites + +**This task is performed by a human in the GitHub and Fly web UIs.** An agent cannot complete it โ€” it requires dashboard access and secret material. An agent executing this plan should present these steps to the operator and wait, then run the verification steps. + +**Files:** none โ€” this is configuration in GitHub and Fly. + +**Interfaces:** +- Produces: GitHub Environment `production-deploy` with a required reviewer; environment secret `FLY_DEPLOY_TOKEN` scoped to it; a repository ruleset freezing `guarzo/release`; Fly app `wanderer` with no GitHub integration. + +- [ ] **Step 1: Record the current deploy state, so a revert is possible** + +Run: + +```bash +flyctl releases -a wanderer | head -3 +git rev-parse origin/guarzo/release +``` + +Write both down. If this task needs reverting, these are the values to return to. + +- [ ] **Step 2: Disconnect Fly's GitHub integration** + +In the Fly dashboard: app `wanderer` โ†’ Settings โ†’ find the GitHub integration/repository connection โ†’ disconnect. + +This is **first** deliberately. Until Task 2 lands there is no CI deploy path, and `flyctl deploy` from a workstation is the interim one. Doing it last instead would mean the first workflow run deploys twice โ€” two full restarts of the single machine for one release. + +- [ ] **Step 3: Verify no push-triggered deploy remains** + +Push any trivial commit to `guarzo/release` (or simply wait for the next one) and confirm no new Fly release appears: + +```bash +flyctl releases -a wanderer | head -3 +``` + +Expected: the release counter is unchanged from Step 1. + +If a new release appears, the integration is still connected โ€” stop and resolve that before continuing. Everything downstream assumes exactly one system deploys. + +- [ ] **Step 4: Create the `production-deploy` environment** + +GitHub โ†’ repository Settings โ†’ Environments โ†’ New environment โ†’ name it exactly `production-deploy`. + +Do **not** reuse the existing `production` environment. It was created 2026-08-05, most likely by Fly's integration for its own deployment records, and the interaction is untested. + +Enable **Required reviewers** and add yourself. + +- [ ] **Step 5: Verify the environment exists with protection** + +Run: + +```bash +gh api repos/guarzo/wanderer/environments/production-deploy \ + --jq '{name, protection_rules: [.protection_rules[].type]}' +``` + +Expected: `{"name":"production-deploy","protection_rules":["required_reviewers"]}` + +If `protection_rules` is empty, the reviewer was not saved โ€” the gate would not gate anything. + +- [ ] **Step 6: Create the Fly deploy token** + +Run: + +```bash +flyctl tokens create deploy -a wanderer +``` + +Copy the full output token, including the `FlyV1 ` prefix. + +Deploy-scoped, not a personal org token: a compromised runner must not be able to reach `kills`, `route-builder`, or anything else in the org. + +- [ ] **Step 7: Store it as an environment secret** + +GitHub โ†’ Settings โ†’ Environments โ†’ `production-deploy` โ†’ **Environment secrets** โ†’ Add secret โ†’ name `FLY_DEPLOY_TOKEN`, value from Step 6. + +**Environment secret, not repository secret.** A repository secret is readable by any workflow on a trusted branch; an environment secret is released only to a job that has cleared the approval gate. The whole premise is that nothing reaches production without approval, and the credential is part of "nothing". + +- [ ] **Step 8: Verify the secret is scoped to the environment, not the repo** + +Run: + +```bash +gh api repos/guarzo/wanderer/environments/production-deploy/secrets --jq '.secrets[].name' +gh api repos/guarzo/wanderer/actions/secrets --jq '.secrets[].name' +``` + +Expected: `FLY_DEPLOY_TOKEN` appears in the **first** output and **not** in the second. + +If it appears in the second, it was created as a repository secret โ€” delete it there and redo Step 7. + +- [ ] **Step 9: Freeze `guarzo/release` with a ruleset** + +`guarzo/release` is retired: the workflow does not push it, and nothing reads it. The ruleset exists solely so the old hard-reset-and-push habit fails loudly instead of silently doing nothing. + +GitHub โ†’ Settings โ†’ Rules โ†’ Rulesets โ†’ New branch ruleset: + +- Name: `guarzo/release frozen` +- Enforcement: Active +- Target branches: include `refs/heads/guarzo/release` +- Rules: enable **Restrict updates** and **Restrict deletions** +- **Bypass list: empty.** Nothing needs to write to this branch, including Actions. + +The equivalent API call, from `.superpowers/sdd/2026-08-07-deploy-approval-gate/release-ruleset.json`: + +```bash +gh api repos/guarzo/wanderer/rulesets --method POST \ + --input .superpowers/sdd/2026-08-07-deploy-approval-gate/release-ruleset.json +``` + +**Do not add a `GitHub Actions` bypass actor.** It is not needed here, and it is not available: `guarzo/wanderer` is user-owned, and GitHub rejects the `Integration` actor type on user-owned repositories with *"Actor GitHub Actions integration must be part of the ruleset source or owner organization"* (verified, HTTP 422). + +- [ ] **Step 10: Verify the ruleset** + +Run: + +```bash +gh api repos/guarzo/wanderer/rulesets --jq '.[] | {id, name, enforcement}' +``` + +Note the id of `guarzo/release frozen`, then: + +```bash +gh api repos/guarzo/wanderer/rulesets/ \ + --jq '{conditions: .conditions.ref_name.include, bypass: .bypass_actors, rules: [.rules[].type], can_bypass: .current_user_can_bypass}' +``` + +Expected: the include list contains `refs/heads/guarzo/release`, `bypass` is `[]`, `rules` contains `update` and `deletion`, and `can_bypass` is `"never"`. + +- [ ] **Step 11: Confirm a direct push is now refused** + +Run: + +```bash +git push origin origin/guarzo/zoo:guarzo/release --force +``` + +Expected: **rejected** by the ruleset, with a message naming the rule. + +A push that *succeeds* here means the ruleset is not protecting the branch โ€” fix it before continuing. + +- [ ] **Step 12: Delete the unused `production` environment (optional)** + +An empty, unprotected environment named `production` exists alongside `production-deploy`. Nothing references it. Two similarly named environments where only one gates anything is a footgun during an incident, so delete it: + +```bash +gh api repos/guarzo/wanderer/environments/production --method DELETE +``` + +--- + +### Task 2: The deploy workflow + +**Files:** +- Create: `.github/workflows/zoo-deploy.yml` + +**Interfaces:** +- Consumes: environment `production-deploy` and environment secret `FLY_DEPLOY_TOKEN` from Task 1. +- Produces: a workflow named `๐Ÿš€ Zoo Deploy` triggerable by `workflow_dispatch` (with an optional `ref` input) and by the `๐Ÿงช Test Suite` workflow completing on `guarzo/zoo`. + +- [ ] **Step 1: Resolve the action SHAs to pin** + +Every third-party action is pinned to a full commit SHA. Two are needed: + +```bash +gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha' +gh api repos/superfly/flyctl-actions/git/ref/tags/master --jq '.object.sha' +``` + +If `superfly/flyctl-actions` has no `master` ref, list what it does have: + +```bash +gh api repos/superfly/flyctl-actions/git/refs --jq '.[].ref' +``` + +Use the resolved SHAs in Step 2 in place of `` and ``, keeping the trailing version comment. + +- [ ] **Step 2: Write the workflow** + +Create `.github/workflows/zoo-deploy.yml`: + +```yaml +name: ๐Ÿš€ Zoo Deploy + +# Deploys are gated on a green test suite, so the trigger is the test workflow +# finishing โ€” not the push itself. An environment gate does not wait for another +# workflow's checks, so `on: push` would offer approval while the suite is still +# running, and a red commit could be approved and shipped. +on: + workflow_run: + workflows: ["๐Ÿงช Test Suite"] + types: [completed] + branches: [guarzo/zoo] + workflow_dispatch: + inputs: + ref: + description: 'Tag or SHA to deploy (defaults to guarzo/zoo HEAD)' + required: false + type: string + +# Default token is read-only; the job scopes itself up because it pushes a tag. +# The tag is the ONLY record of what is in production โ€” no branch tracks it, by +# design (see docs/ZOO-FORK.md, "Deployment"). +permissions: + contents: read + +jobs: + deploy: + name: Deploy to Fly + # workflow_run fires on EVERY completion of the test suite โ€” GitHub offers + # no conclusion filter on the trigger itself, so a red suite still creates a + # Zoo Deploy run. This condition is what makes it inert: the single job is + # skipped, so no environment is referenced, no approval is requested, and no + # credential is released. Expect skipped runs in the Actions tab after every + # failed suite; that is the mechanism working, not a misfire. + # + # The `event == 'push'` clause guards a narrower hole: workflow_run's + # `branches:` filter (above) matches the triggering run's head_branch, and + # this is a public fork whose default branch is itself named `guarzo/zoo`. + # Without this clause, a fork PR whose source branch is also named + # `guarzo/zoo` would match the filter and, if its tests pass, raise a + # production-deploy approval request for a commit the requester controls. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push') + runs-on: ubuntu-latest + + # The gate AND the work live in one job on purpose. A protected environment + # gates every job that references it, so splitting them would prompt twice; + # and FLY_DEPLOY_TOKEN is an environment secret, readable only by a job inside + # the environment. Secrets cannot be passed between jobs. + environment: production-deploy + + permissions: + contents: write + + # NEVER set cancel-in-progress: true here. Cancellation applies to running + # jobs, not just to runs awaiting approval: a merge landing mid-deploy would + # kill this job while Fly's builder keeps going, changing production with no + # tag โ€” and the tag is the only record of what is live. That is the exact + # failure this workflow exists to prevent. `false` also serializes deploys + # onto the single machine. + concurrency: + group: zoo-deploy-run + cancel-in-progress: false + + steps: + - name: Resolve the ref to deploy + id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_REF: ${{ inputs.ref }} + RUN_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + REF="${DISPATCH_REF:-guarzo/zoo}" + else + # NOT github.ref: under workflow_run that resolves to the default + # branch tip at trigger time, which may not be the tested commit. + REF="$RUN_SHA" + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "Resolved deploy ref: $REF" + + - name: Check out the ref + uses: actions/checkout@ # v4 + with: + ref: ${{ steps.resolve.outputs.ref }} + # Annotated tagging needs full history. + fetch-depth: 0 + + # Runs AFTER approval, which is the entire point: a run may sit pending + # for hours while guarzo/zoo moves on. Because runs are never cancelled, + # this is what stops an old approval from shipping a superseded commit. + - name: Guard against a superseded commit + id: guard + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "workflow_dispatch: staleness guard skipped โ€” deploying a ref that is not the branch tip is what rollback is for." + echo "proceed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch --quiet origin guarzo/zoo + TIP="$(git rev-parse origin/guarzo/zoo)" + HERE="$(git rev-parse HEAD)" + if [ "$TIP" != "$HERE" ]; then + echo "::notice title=Superseded::guarzo/zoo has moved to ${TIP}; this run was approved for ${HERE}. Nothing was deployed." + # Also write to the job summary, not just the notice: a notice + # requires opening the run to see, so without this the summary + # pane stays blank and a superseded run reads identically to a + # real deploy in the Actions list. + { + echo "### Not deployed โ€” superseded" + echo "" + echo "guarzo/zoo has moved to \`${TIP}\`; this run was approved for \`${HERE}\`. Nothing was deployed." + } >> "$GITHUB_STEP_SUMMARY" + echo "proceed=false" >> "$GITHUB_OUTPUT" + else + echo "proceed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Set up flyctl + if: steps.guard.outputs.proceed == 'true' + uses: superfly/flyctl-actions/setup-flyctl@ # v1 + + # Fly runs release_command first (fly.toml:29 โ€” migrations against + # DIRECT_DATABASE_URL), so a failed migration fails the deploy before new + # code serves traffic. Under strategy = 'rolling' (fly.toml:30) this + # blocks on the /health check (fly.toml:84-88). + - name: Deploy to Fly + if: steps.guard.outputs.proceed == 'true' + id: deploy + env: + # Repository secret name is FLY_DEPLOY_TOKEN, deliberately NOT + # FLY_API_TOKEN: advanced-test.yml reads a secret of that name with no + # environment gate, for the separate wanderer-test app. Distinct names + # mean this production credential can never be picked up there. The + # env var flyctl itself reads is still FLY_API_TOKEN. + FLY_API_TOKEN: ${{ secrets.FLY_DEPLOY_TOKEN }} + run: | + set -euo pipefail + flyctl deploy --app wanderer --remote-only + # Recorded in the tag message so a tag can be matched to a Fly release + # without the dashboard. Best-effort: a lookup failure must not fail a + # deploy that already succeeded. + # The key is `Version`, capital V โ€” flyctl serializes Go field names. + # A lowercase `.version` does not error, it yields null, which reaches + # the tag message as the literal "release vnull". Hence the explicit + # `// "unknown"` and the empty-string guard: every failure mode here + # is silent, so each one needs its own fallback. + VERSION="$(flyctl releases --app wanderer --json | jq -r '.[0].Version // "unknown"' || echo unknown)" + echo "version=${VERSION:-unknown}" >> "$GITHUB_OUTPUT" + + # Everything below runs only after a healthy deploy. That ordering is what + # makes the tag mean "this commit served production traffic". + - name: Tag the deployed commit + if: steps.guard.outputs.proceed == 'true' + id: tag + env: + DEPLOY_VERSION: ${{ steps.deploy.outputs.version }} + run: | + set -euo pipefail + SHA="$(git rev-parse --short HEAD)" + git config user.name "github-actions" + git config user.email "github-actions@github.com" + # Convergent, not merely collision-safe. A recovery re-run (deploy + # succeeded, tag push failed) must reuse the tag the first run + # created, not mint a second one for the same commit โ€” the tag name is + # generated from the clock, so checking only the new name would always + # miss the existing one. + # Tags are present because checkout used fetch-depth: 0. + # 'v20[0-9]*' (not 'v[0-9]*') excludes upstream semver release tags + # like v1.2.3 โ€” git tag sorts lexicographically, so head -n1 would + # otherwise prefer an upstream tag over a timestamped deploy tag on + # any commit carrying both. + EXISTING="$(git tag --points-at HEAD --list 'v20[0-9]*' | head -n1)" + if [ -n "${EXISTING}" ]; then + TAG="${EXISTING}" + echo "Commit is already tagged ${TAG}; reusing it." + else + TAG="v$(date -u +%Y%m%d%H%M%S)" + git tag -a "${TAG}" -m "Deployed ${SHA} to Fly app wanderer (release v${DEPLOY_VERSION})" + git push origin "${TAG}" + echo "Tagged ${SHA} as ${TAG}" + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Summarize + if: steps.guard.outputs.proceed == 'true' + env: + DEPLOY_TAG: ${{ steps.tag.outputs.tag }} + run: | + { + echo "### Deployed" + echo "" + echo "- commit: \`$(git rev-parse HEAD)\`" + echo "- tag: \`${DEPLOY_TAG}\`" + echo "- app: \`wanderer\`" + } >> "$GITHUB_STEP_SUMMARY" +``` + +**Note (final review, 2026-08-07):** the workflow above reflects fixes from the +final whole-branch review โ€” the `event == 'push'` clause on the job `if:` (I-3), +writing the staleness-guard outcome to `$GITHUB_STEP_SUMMARY` (I-1), narrowing +the tag glob to `'v20[0-9]*'` (M-2), routing `steps.deploy.outputs.version` and +`steps.tag.outputs.tag` through `env:` (M-1), and adding `-u` to the tag +timestamp's `date` call. See +`.superpowers/sdd/2026-08-07-deploy-approval-gate/final-review.md`. + +- [ ] **Step 3: Verify the YAML parses** + +Run: + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/zoo-deploy.yml')); print('parses OK')" +``` + +Expected: `parses OK` + +A YAML error here would otherwise surface as a workflow that silently never appears in the Actions tab. + +- [ ] **Step 4: Verify no action is left unpinned** + +Run: + +```bash +grep -n "uses:" .github/workflows/zoo-deploy.yml +``` + +Expected: every line has a 40-character hex SHA and a trailing `# vโ€ฆ` comment. No `@v4`, no `@master`, and no literal `` / `` placeholders left from Step 2. + +- [ ] **Step 5: Verify the two invariants that carry the most risk** + +Run: + +```bash +grep -n -A2 "concurrency:" .github/workflows/zoo-deploy.yml +grep -n "environment:" .github/workflows/zoo-deploy.yml +``` + +Expected: `cancel-in-progress: false`, and exactly one `environment: production-deploy` line. + +`cancel-in-progress: true` here would let a merge kill a running deploy. A second `environment:` line would mean a second approval prompt. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/zoo-deploy.yml +git commit -m "ci: gated deploy workflow for guarzo/zoo + +Triggers on a successful Test Suite run, waits on the production-deploy +environment for approval, then deploys to Fly and tags the commit only after +the release is healthy. Replaces Fly's branch-watch integration on +guarzo/release, which is retired โ€” the deploy tag is now the record of what is +in production." +``` + +- [ ] **Step 7: Open a PR and merge to `guarzo/zoo`** + +```bash +git push -u origin HEAD +gh pr create --base guarzo/zoo --fill +``` + +Merge once `Test Suite` is green. + +**This merge is a hard prerequisite for Task 3.** Both `workflow_run` and `workflow_dispatch` only see workflow files that exist on the default branch. Until this is merged, the workflow cannot be triggered at all โ€” it will not even appear in the Actions tab. + +**The merge arms the automatic path immediately.** `test.yml:6-7` triggers on pushes to `guarzo/zoo`, so merging runs the suite; when it goes green, `workflow_run` fires against the now-present workflow file and opens a **pending deploy run for the merge commit**. Step 9 deals with it โ€” do not skip ahead to Task 3 while it is outstanding. + +- [ ] **Step 8: Verify the workflow is registered** + +Run: + +```bash +gh workflow list | grep -i "Zoo Deploy" +``` + +Expected: one row, state `active`. + +If it does not appear, the file is not on `guarzo/zoo` or the YAML failed to parse server-side. + +- [ ] **Step 9: Cancel the automatic pending run created by the merge** + +Wait for `Test Suite` to finish on the merge commit, then: + +```bash +gh run list --workflow "๐Ÿš€ Zoo Deploy" --limit 3 +``` + +If a run is in state `waiting`, cancel it: + +```bash +gh run cancel +``` + +**Do not approve it, and do not leave it pending.** It targets the same SHA Task 3 will dispatch, so the staleness guard cannot tell the two apart โ€” it compares against the branch tip, and both *are* the branch tip. Approving both deploys the same commit twice: two full restarts of the single machine for one release, with the second appearing uncaused. + +Cancelling rather than approving keeps Task 3 as the validation run. Task 3 exercises `workflow_dispatch`, which is also the rollback path, so it is the trigger worth proving deliberately; the automatic path gets its own coverage in Task 4. + +- [ ] **Step 10: Verify nothing is left pending** + +Run: + +```bash +gh run list --workflow "๐Ÿš€ Zoo Deploy" --limit 5 +``` + +Expected: no run in state `waiting`. Anything `completed` or `cancelled` is fine. + +Task 3 starts from a clean queue, so that the run it approves is unambiguously the one it triggered. + +--- + +### Task 3: Validation deploy + +Proves the pipeline end to end with a deliberate release. This costs one restart of the single machine โ€” cheaper than discovering a broken pipeline during a real change. + +**Files:** none. + +**Interfaces:** +- Consumes: the merged workflow from Task 2 and all configuration from Task 1. +- Produces: confirmation that self-approval works, the credential resolves, the deploy succeeds, and the commit is tagged. + +- [ ] **Step 1: Record the pre-deploy state** + +```bash +flyctl releases -a wanderer | head -3 +git fetch origin --tags +git for-each-ref --sort=-creatordate --format='%(creatordate:iso) %(refname:short)' refs/tags | head -3 +git rev-parse origin/guarzo/release origin/guarzo/zoo +``` + +Note the Fly release number, the newest tag, and both branch SHAs. + +- [ ] **Step 2: Trigger the workflow** + +```bash +gh workflow run "๐Ÿš€ Zoo Deploy" +``` + +Then find the run: + +```bash +gh run list --workflow "๐Ÿš€ Zoo Deploy" --limit 1 +``` + +- [ ] **Step 3: Verify it is waiting for approval, and approve it** + +Expected: status `waiting`, not `in_progress` or `completed`. + +If it ran straight through without waiting, the environment protection is not attached โ€” stop, and recheck Task 1 Step 5. + +Approve it in the GitHub UI (Actions โ†’ the run โ†’ Review deployments โ†’ Approve and deploy). + +**This is the first thing to confirm and the most consequential.** If GitHub refuses to let you approve a run you triggered, the gate is unopenable with a single required reviewer, and the design needs a second reviewer or a different protection rule. Everything else is moot if this fails. + +- [ ] **Step 4: Watch the run** + +```bash +gh run watch +``` + +Expected: all steps green. Note in particular that `Guard against a superseded commit` reports the `workflow_dispatch` skip message rather than a staleness comparison. + +- [ ] **Step 5: Verify the deploy actually happened** + +```bash +flyctl releases -a wanderer | head -3 +flyctl status -a wanderer +``` + +Expected: the release counter incremented by one from Step 1; the machine is `started` and passing health checks. + +- [ ] **Step 6: Verify the tag** + +```bash +git fetch origin --tags +git for-each-ref --sort=-creatordate --format='%(creatordate:iso) %(refname:short)' refs/tags | head -3 +git rev-parse "$(git for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags | head -1)" +``` + +Expected: a new `v2026โ€ฆ` tag exists, newer than the one recorded in Step 1, pointing at the SHA that was deployed. + +- [ ] **Step 7: Verify the tag push is the only ref write โ€” the step most likely to fail** + +```bash +git fetch origin --tags --prune +git rev-parse origin/guarzo/release +``` + +Expected: `guarzo/release` is **unchanged** from the SHA recorded in Step 1. The workflow no longer touches it, and the Task 1 Step 9 ruleset rejects any push to it. + +**If the run failed at `Tag the deployed commit`,** production is *already deployed* at this point and the deployed commit carries no tag โ€” the partial-failure state the spec describes, and now the only one, since the tag is the sole production record. The likely cause is the job's `permissions: contents: write` not taking effect. Fix it, then recover by re-running against the explicit SHA: + +```bash +gh workflow run "๐Ÿš€ Zoo Deploy" -f ref="$(git rev-parse origin/guarzo/zoo)" +``` + +and approving it. The tag step is convergent and the deploy is a no-op change, at the cost of one more restart. + +- [ ] **Step 8: Verify the app is actually serving** + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' https://wanderer.fly.dev/health +``` + +Expected: `200`. + +Substitute the real public hostname if the app is served from a custom domain. + +--- + +### Task 4: Trigger validation + +`workflow_dispatch` cannot exercise the trigger itself. This task proves the property the whole design rests on: a deploy run **requests approval** only after a green suite. + +Note the precise claim. A red suite does not suppress the run โ€” `workflow_run` has no conclusion filter, so GitHub creates a Zoo Deploy run for every completion and the job-level `if:` skips it. What a red suite suppresses is the approval request and everything downstream of it. The verifications below check for that, not for the absence of a run. + +**Files:** none (uses a throwaway commit). + +**Interfaces:** +- Consumes: everything from Tasks 1โ€“3. +- Produces: confirmation that `workflow_run` fires correctly and that a red suite produces a skipped run rather than an approvable one. + +- [ ] **Step 1: Push a trivial commit to `guarzo/zoo`** + +Any no-op change is fine โ€” a comment or a whitespace fix in a file that does not affect behavior. Merge it the normal way. + +- [ ] **Step 2: Verify no deploy run appears while the suite is still running** + +Immediately after the merge: + +```bash +gh run list --workflow "๐Ÿš€ Zoo Deploy" --limit 3 +gh run list --workflow "๐Ÿงช Test Suite" --limit 3 +``` + +Expected: the `Test Suite` run is `in_progress`; **no** new Zoo Deploy run yet. + +`workflow_run` fires on `completed`, so nothing should exist until the suite finishes. A Zoo Deploy run appearing here means the trigger is wrong โ€” likely reverted to `on: push` โ€” and a commit could be approved before its tests finish. + +- [ ] **Step 3: Verify it appears after the suite goes green** + +Once `Test Suite` completes successfully: + +```bash +gh run list --workflow "๐Ÿš€ Zoo Deploy" --limit 1 +``` + +Expected: a new run, status `waiting` (awaiting approval). + +- [ ] **Step 4: Verify the staleness guard, then leave the run unapproved** + +Push a second trivial commit to `guarzo/zoo` and let its suite finish, so two runs are now pending. Approve the **older** one. + +Expected: it completes successfully **without deploying**, and the `Guard against a superseded commit` step logs the `Superseded` notice naming the newer tip. The Fly release counter must be unchanged. + +This is the behavior that replaces cancellation. If the older run deploys, the guard is broken and stale approvals can ship superseded commits. + +- [ ] **Step 5: Approve the newest run, or dismiss both** + +Either approve the newest pending run to ship the trivial commits, or cancel the pending runs to leave production where it is. Both are valid; just do not leave the queue ambiguous. + +- [ ] **Step 6: Confirm a red suite produces a skipped run, not an approvable one** + +Verify from history rather than by breaking the suite deliberately. Find past `Test Suite` runs on `guarzo/zoo` that concluded `failure`, then check what the corresponding Zoo Deploy runs did: + +```bash +gh run list --workflow "๐Ÿงช Test Suite" --branch guarzo/zoo --status failure --limit 5 \ + --json headSha,conclusion,createdAt +gh run list --workflow "๐Ÿš€ Zoo Deploy" --limit 20 \ + --json headSha,conclusion,status,createdAt +``` + +Expected: for any head SHA appearing in both lists, the Zoo Deploy run has conclusion `skipped` (or `success` with the job skipped) and **never** reached `waiting`. Confirm on one such run: + +```bash +gh run view --json jobs --jq '.jobs[] | {name, conclusion}' +``` + +Expected: the `Deploy to Fly` job's conclusion is `skipped`. + +A run in state `waiting` against a red SHA is the failure that matters โ€” it means the `if:` condition is wrong and a red commit is one click from production. A *skipped* run against a red SHA is the design working. + +**If no failed `Test Suite` run exists on `guarzo/zoo` yet,** this check has nothing to read. Record that it was not exercised rather than marking it done; do not merge a deliberately broken commit to the default branch to manufacture one, on a fork that is regularly rebased. + +--- + +### Task 5: Document the deploy process + +The spec does not require this. It is included because the process being undocumented is what produced the original problem โ€” the reason for the old `guarzo/release` reset had been forgotten, and the tags disappearing went unnoticed for two days. + +**Files:** +- Modify: `docs/ZOO-FORK.md` + +**Interfaces:** +- Consumes: the finished pipeline from Tasks 1โ€“4. +- Produces: no code interface. + +- [ ] **Step 1: Add the section** + +Insert into `docs/ZOO-FORK.md` immediately **after** the `## Upstream PR Recommendations` section (currently starting at line 207) and **before** `## Key Files Reference` (currently line 272). That position keeps the seven TOC-listed sections contiguous โ€” `## Key Files Reference` (272) and `## Maintenance Notes` (324) sit below the TOC's range and are not listed in it. + +```markdown +## Deployment + +Production is the Fly app `wanderer` (single machine โ€” see the constraint +comment at the top of `fly.toml`). + +**How a change reaches production:** + +1. Merge to `guarzo/zoo`. +2. `๐Ÿงช Test Suite` runs. If it fails, a `๐Ÿš€ Zoo Deploy` run still appears in the + Actions tab but its only job is **skipped** โ€” no approval is requested and + nothing can be deployed. Skipped deploy runs after a red suite are normal. +3. On success, `๐Ÿš€ Zoo Deploy` opens a run that **waits for approval** in the + `production-deploy` environment. GitHub emails an approval request; the run + also shows as pending in the Actions tab. +4. Approving it deploys to Fly, then tags the commit `v`. + +**The newest `v20*` tag is the record of what is in production.** No branch +tracks it. To see what you have written but not yet deployed: + +```bash +git fetch origin --tags +git log --oneline "$(git tag -l 'v20*' | sort | tail -1)"..guarzo/zoo +``` + +**`guarzo/release` is retired.** It used to be the deploy trigger โ€” hard-resetting +and pushing it was how you shipped. It no longer moves, deploys nothing, and is +frozen by a ruleset that rejects pushes to it, so the old habit fails loudly +instead of silently doing nothing. It survives only as a marker of where the +old process stopped. + +**Nothing deploys without approval**, including a run that has been sitting +pending. Pending approvals expire after 30 days. + +**To roll back**, run `๐Ÿš€ Zoo Deploy` manually with `ref` set to a previous tag +and approve it. Note that migrations only run forward โ€” a rollback does not +revert a schema change. + +**Approving a stale run is safe.** If `guarzo/zoo` has moved on since the run +was created, the workflow exits without deploying and says so. +``` + +- [ ] **Step 2: Add it to the table of contents** + +In the `## Table of Contents` list at the top of `docs/ZOO-FORK.md` (lines 12โ€“18), add as entry 8: + +```markdown +8. [Deployment](#deployment) +``` + +- [ ] **Step 3: Verify the anchor resolves** + +Run: + +```bash +grep -n "^## Deployment" docs/ZOO-FORK.md +grep -n "(#deployment)" docs/ZOO-FORK.md +``` + +Expected: both return exactly one line. + +- [ ] **Step 4: Commit** + +```bash +git add docs/ZOO-FORK.md +git commit -m "docs: describe the gated deploy process + +The old process was undocumented, which is how the guarzo/release reset became +folklore and how the deploy tags going away went unnoticed." +``` + +--- + +## Rollback for the whole change + +Order matters, and it is the reverse of the rollout. + +1. **Disable or delete `.github/workflows/zoo-deploy.yml`** first. +2. Delete the `guarzo/release frozen` ruleset, so the branch can move again. +3. Hard-reset `guarzo/release` to `guarzo/zoo` and push it โ€” it has been frozen since Task 1, so it is stale by however many deploys have happened. +4. Reconnect Fly's GitHub integration to `guarzo/release`. +5. Optionally delete the `production-deploy` environment and its secret. + +Steps 2 and 3 must precede step 4. Reconnecting Fly first would make the next hard-reset push deploy whatever `guarzo/release` was frozen at โ€” an old commit โ€” rather than current `guarzo/zoo`. + +Unlike the earlier design that kept `guarzo/release` as a live bookmark, this rollback requires an explicit catch-up push: the branch stopped tracking production the moment the workflow landed. The newest `v20*` tag records what was actually deployed in the interim. + +## Known risks carried into implementation + +- **`flyctl deploy` may not exit non-zero on a failed health check.** The tag step depends on it. If Task 3 shows a deploy reported as successful while the machine is unhealthy, add an explicit `flyctl status` / `/health` poll between the deploy and tag steps. +- **Steps 2โ€“5 of the job are not atomic.** A failure after the deploy leaves production changed but untagged. Recovery is the idempotent `workflow_dispatch` re-run in Task 3 Step 7, at the cost of one restart. +- **Self-approval is assumed and verified in Task 3 Step 3.** If it does not hold, the design needs a second reviewer. +- **`superfly/flyctl-actions/setup-flyctl` is a new dependency** on this repo. If pinning proves awkward, installing flyctl directly (`curl -L https://fly.io/install.sh | sh`) is a viable substitute, but a piped installer is a worse supply-chain posture than a pinned action. diff --git a/docs/superpowers/plans/2026-08-07-discord-route-alerts.md b/docs/superpowers/plans/2026-08-07-discord-route-alerts.md new file mode 100644 index 000000000..332fffd07 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-discord-route-alerts.md @@ -0,0 +1,4901 @@ +# Discord Route Alerts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Post a Discord message when a change to a map's topology opens a short, highsec-only route from the map's configured home system to Jita. + +**Architecture:** A new `do_dispatch/2` clause on the existing singleton `DiscordDispatcher` casts to a per-map `RouteWatcher` GenServer, which owns a debounce timer and the last-known route state and never blocks on the solver. The watcher runs `Routes.find_strict/5` โ€” a new non-swallowing sibling of `Routes.find/5` โ€” in a supervised, non-blocking task, hands the result to a pure `RouteAlert.Evaluator`, and on a reportable transition formats an embed and hands it to the existing per-webhook delivery queue. Every security and jump-counting rule lives in the Evaluator so it can be verified with synthetic input and no HTTP. + +**Tech Stack:** Elixir 1.17 / OTP 26, Phoenix LiveView, Ash Framework 3.9, Cachex, Nostrum (already present), `Task.Supervisor`, `DynamicSupervisor` + `Registry`. + +## Global Constraints + +- **Branch base is `guarzo/zoo`, not `origin/main`.** The entire Discord stack (`DiscordDispatcher`, `Discord.Router`, `Discord.WorkerSupervisor`, `MapDiscordWebhook`, `MapDiscordNotification`) exists only on `guarzo/zoo`. A worktree cut from `main` will not compile against this plan. +- **Ash actions, never raw Ecto.** Every new or changed action needs a matching `define(...)` entry in the resource's `code_interface` block. +- **Cache invalidation goes in `after_transaction`, never `after_action`** โ€” see the rationale comment at `map_discord_notification.ex:147-164`. +- **Migrations are generated, not hand-written:** `mix ash.codegen `. +- **This feature fails closed.** Any system on the path whose static info will not resolve disqualifies the route. This inverts the fail-open posture of the neighbouring kill path and is deliberate: announcing a highsec route that is not one gets a freighter killed; a missed alert costs nothing. +- **Highsec threshold is `>= 0.45`**, matching EVE's display rounding. `route_builder_client.ex:136` uses `>= 0.5` โ€” do not copy it. Wormhole systems are exempt from the security check entirely; the Evaluator already holds the system's `system_class`, so the check is `SystemClass.wormhole?(class)` (`system_class.ex:52`), not the id-taking `wormhole_system?/1`. +- **Jita is `30_000_142`.** Both `origin` and `hubs` passed to the solver must be **strings** โ€” `do_find_routes` calls `String.to_integer/1` on each (`map_routes.ex:94-95`). +- **The `SystemName.display_name/3` role argument must be a literal matched atom**, never a threaded variable. That resolver is the map-local-names privacy boundary. +- **The disabled-drops-never-reroutes rule:** a configured-but-disabled webhook drops; it never falls through to another role. `RouterTest` asserts this deliberately. +- **Ship off:** `route_alerts_enabled?` defaults to `false`. +- Run `mix format` before every commit. Verification commands run from the worktree root. + +## File Structure + +**Create:** + +| File | Responsibility | +|---|---| +| `lib/wanderer_app/map/route_alert/evaluator.ex` | Pure: solver output + settings -> `{:qualifying, ...}` \| `:none` \| `:unknown`. All security and jump-counting rules. | +| `lib/wanderer_app/external_events/discord/mentions.ex` | Mention target validation, `content` prefix, and `allowed_mentions` construction. | +| `lib/wanderer_app/external_events/discord/route_watcher.ex` | GenServer, one per map. Debounce timer, route state, `config_version`, solver task ref. | +| `lib/wanderer_app/external_events/discord/route_watcher_supervisor.ex` | `DynamicSupervisor` + `Registry`; `notify/1`, `stop_watcher/1`. | + +**Modify:** + +| File | Change | +|---|---| +| `lib/wanderer_app/map/map_routes.ex` | Add `find_strict/5`; extract `hydrate_static_data/1`; add a swappable ESI seam. `find/5`'s observable behaviour must not change โ€” see "Review focus". | +| `lib/wanderer_app/api/map_discord_notification.ex` | Three new attributes, validation, `stop_watcher` on destroy. | +| `lib/wanderer_app/api/map_discord_webhook.ex` | `mention_targets` attribute; `:route` joins the `role` `one_of`. | +| `lib/wanderer_app/env.ex` | `discord_mentions_enabled?/0`. | +| `lib/wanderer_app/external_events/discord/router.ex` | `route_destination/1`. | +| `lib/wanderer_app/external_events/discord/embed_formatter.ex` | `format_route_alert/2`. | +| `lib/wanderer_app/external_events/discord/system_name.ex` | `:route` clause on `display_name/3` if absent. | +| `lib/wanderer_app/external_events/discord_dispatcher.ex` | Topology `do_dispatch/2` clause before the catch-all; `allowed_mentions` at the payload-assembly point. | +| `lib/wanderer_app/application.ex` | Start `RouteWatcherSupervisor` inside the `webhooks_enabled` list, before `DiscordDispatcher`. | +| `lib/wanderer_app_web/live/maps/map_notifications_component.ex` | Settings UI. | + +## Baseline + +Established in this worktree before planning: + +``` +mix deps.get # exit 0 +MIX_ENV=test mix compile # exit 0 +mix test test/unit/external_events/ \ + test/unit/api/map_discord_notification_test.exs \ + test/unit/api/map_discord_webhook_test.exs # 321 tests, 0 failures +``` + +If `mix deps.compile sleeplocks --force` is ever needed, that is a stale rebar artifact in the worktree's `_build`, not a code problem. + +## Review focus + +Three places where this plan touches code that is live in production today. The +rest of the plan is new modules that cannot regress anything. + +1. **`map_routes.ex` (Task 1)** โ€” the ESI seam and the `hydrate_static_data/1` + extraction sit in the path behind the live routes widget. Task 1 carries a + regression test asserting `find/5` still falls back to `get_routes_eve/4`; + confirm it actually exercises the old path rather than the new one. +2. **`Worker.do_post/2` (Task 4)** โ€” `allowed_mentions` is attached at the single + funnel before `HttpClient.post`, which means kill notifications and voice + mentions start carrying it too. This is a latent-gap hardening, not a fix for + a live exploit: no user-controlled text reaches `content` today. Verify the + existing kill and voice tests still pass. +3. **`DiscordDispatcher` (Task 9)** โ€” a new `do_dispatch/2` clause on a singleton + GenServer. It must do cache reads and a cast only; anything heavier blocks + every Discord notification on the instance. + +**Plan provenance:** the steps below were written from source inspection, not +from executing them. `Expected: FAIL with ...` strings are predictions to verify, +not observed output. Where a step's RED state depends on an earlier step in the +same task, the plan says so explicitly. + +--- + +## Shared Interface Contract + +Every task section must use these exact names, arities, and types. If your +section needs something not listed here, it belongs to another task โ€” reference +it by the signature below rather than inventing a new one. + +## Task 1 โ€” `WandererApp.Map.Routes` (modify `lib/wanderer_app/map/map_routes.ex`) + +```elixir +@spec find_strict(binary(), [binary()], binary(), map(), boolean()) :: + {:ok, %{routes: [route_entry()], systems_static_data: [map()]}} | {:error, term()} +def find_strict(map_id, hubs, origin, routes_settings, hubs_limit_reached?) +``` + +**The 5th argument is `hubs_limit_reached?`, not "avoid wormholes".** Verified: +`find/5`'s `true` clause (`map_routes.ex:80-91`) skips the solver entirely and +fabricates a `success: false` placeholder per hub, and its only callers pass +`is_hubs_limit_reached` (`map_routes_event_handler.ex:96,105`). Route alerts +always pass `false`. A caller that read this as "avoid wormholes" would silently +skip the solver. + +`route_entry()` is the existing shape produced by `map_route_info/1` +(`map_routes.ex:332-338`) โ€” do not redefine it: + +```elixir +%{ + has_connection: boolean(), + systems: [integer()], # hops AFTER origin, ending at destination; origin excluded + origin: integer(), + destination: integer(), + success: boolean() +} +``` + +`systems_static_data` entries are `Map.take(system, @minimum_route_attrs)` +(`map_routes.ex:22-31`) and **may contain `nil`** (`map_routes.ex:62`). + +## Task 2 โ€” `WandererApp.Map.RouteAlert.Evaluator` (create) + +```elixir +@type outcome :: + {:qualifying, %{jumps: pos_integer(), path: [integer()], exit_system: integer() | nil}} + | :none + | :unknown + +@spec evaluate({:ok, map()} | {:error, term()}, keyword()) :: outcome() +def evaluate(solver_result, opts) # opts: [max_jumps: pos_integer()] + +@spec solver_settings() :: map() +@spec jita_system_id() :: 30_000_142 +@spec highsec_threshold() :: float() +``` + +`path` is `[origin | entry.systems]` โ€” the full path including the home system. +`exit_system` is the first non-wormhole system on `path`, or `nil` if there is none. + +## Task 3 โ€” Ash resources + +`WandererApp.Api.MapDiscordNotification` gains: + +```elixir +attribute :route_alerts_enabled?, :boolean, default: false, allow_nil?: false +attribute :home_system_id, :integer # nullable +attribute :route_max_jumps, :integer, default: 5, allow_nil?: false +``` + +`WandererApp.Api.MapDiscordWebhook` gains: + +```elixir +attribute :mention_targets, {:array, :string} do + default [] + allow_nil? false +end +``` + +and its `role` constraint becomes `one_of: [:system, :character, :route]`. + +Mention target format: `"user:<17-20 digits>"` or `"role:<17-20 digits>"`. + +## Task 4 โ€” mentions + +```elixir +@spec WandererApp.Env.discord_mentions_enabled?() :: boolean() + +defmodule WandererApp.ExternalEvents.Discord.Mentions do + @spec prefix([String.t()]) :: String.t() | nil + @spec allowed_mentions([String.t()]) :: map() + @spec valid_target?(String.t()) :: boolean() +end +``` + +`allowed_mentions/1` always returns a map containing `"parse" => []`, even for `[]`. + +## Task 5 โ€” `WandererApp.ExternalEvents.Discord.Router` + +```elixir +@spec route_destination(struct()) :: {:ok, struct()} | :drop +def route_destination(notification) +``` + +## Task 6 โ€” `WandererApp.ExternalEvents.Discord.EmbedFormatter` + +```elixir +@spec format_route_alert(alert :: map(), opts :: keyword()) :: [map()] +``` + +`alert` is `%{kind: :opened | :improved, jumps: pos_integer(), path: [integer()], +exit_system: integer() | nil, map_id: binary(), home_system_id: integer()}`. +`opts`: `[mention_targets: [String.t()]]`. Returns Discord message chunks +(maps with `"embeds"`, and `"content"` / `"allowed_mentions"` when pinging). + +## Task 7 โ€” `WandererApp.ExternalEvents.Discord.RouteWatcher` + +```elixir +@spec notify(binary()) :: :ok +@spec config_version(struct()) :: binary() # hash of {home_system_id, route_max_jumps, settings} +``` + +## Task 8 โ€” `WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor` + +```elixir +@spec notify(binary()) :: :ok # starts the watcher on demand; :ok when not running +@spec stop_watcher(binary()) :: :ok +``` + +## Existing things you may rely on (verified in this worktree) + +| Thing | Location | +|---|---| +| `Task.Supervisor` already started, named `WandererApp.ExternalEvents.Discord.TaskSupervisor` | `worker_supervisor.ex:34` | +| `WorkerSupervisor.deliver(webhook_id, messages)` -> `:ok \| {:error, :not_running}` | `worker_supervisor.ex:57` | +| `DiscordDispatcher.invalidate_cache(map_id)` | `discord_dispatcher.ex:202` | +| `DiscordDispatcher` catch-all `defp do_dispatch(_map_id, _event), do: :ok` | `discord_dispatcher.ex:272` | +| `fetch_config/1` reads the Cachex config cache | `discord_dispatcher.ex:758` | +| `Env.webhooks_enabled?()` | `env.ex:95` | +| `SystemClass.wormhole_classes/0`, `SystemClass.wormhole?/1` (takes a **class**), `SystemClass.wormhole_system?/1` (takes a **solar_system_id**) | `system_class.ex:49,52,60` | +| `VoiceParticipants.prepend_to_messages(messages, prefix)` | `voice_participants.ex:147` | +| Supervision list, gated on `webhooks_enabled` | `application.ex:265-282` | +| Topology events `:add_system`, `:connection_added`, `:connection_updated` are all external | `event.ex:94,101,103` | + +--- + +## Part 1 โ€” solver + +### Task 1: `WandererApp.Map.Routes.find_strict/5` + +**Files:** +- Modify: `lib/wanderer_app/map/map_routes.ex:223-252` (`get_all_routes/4`) +- Modify: `lib/wanderer_app/map/map_routes.ex:45-97` (`find/5`, `do_find_routes/4`) +- Modify: `test/support/mock_definitions.ex:127-141` (`WandererApp.Esi.MockBehaviour`) +- Test: `test/unit/map/map_routes_find_strict_test.exs` + +**Interfaces:** +- Consumes: `WandererApp.Esi.get_routes_custom/3`, `WandererApp.Esi.get_routes_eve/4` + (both routed through a new swappable seam), `WandererApp.CachedInfo.get_system_static_info/1`, + `WandererApp.Cache.lookup/1`, `WandererApp.Cache.insert/3`. +- Produces (per `00-contract.md`, Task 1): + ```elixir + @spec find_strict(binary(), [binary()], binary(), map(), boolean()) :: + {:ok, %{routes: [route_entry()], systems_static_data: [map()]}} | {:error, term()} + def find_strict(map_id, hubs, origin, routes_settings, hubs_limit_reached?) + ``` + +**Naming note carried into the code as a comment:** the 5th positional argument +is `hubs_limit_reached?`, not "avoid wormholes". Inspection of `find/5`'s two +clauses (`map_routes.ex:45,80`) and its only two callers +(`map_routes_event_handler.ex:100-106,142-151`) shows that when it is `true` the +solver is skipped entirely and a `success: false` placeholder is fabricated per +hub, regardless of wormhole avoidance. `find_strict/5` mirrors this exactly, +because it mirrors `find/5`. Document the meaning in the `@doc`: a caller +misreading it as "avoid wormholes" and passing `true` would silently disable the +feature while looking like a security tightening. (Wormhole avoidance is a +separate thing entirely โ€” the `:avoid_wormholes` **key inside the +`routes_settings` map**, which is unrelated to this argument.) + +- [ ] **Step 1: Extend the ESI mock behaviour โ€” prerequisite for the seam** + +`WandererApp.Esi.get_routes_custom/3` and `get_routes_eve/4` are called +directly as `WandererApp.Esi.xxx(...)` in `map_routes.ex` today +(`map_routes.ex:232,249`) โ€” there is no `Application.get_env(:wanderer_app, +:esi_client, ...)` seam the way `CorpTickers.esi_client/0` has +(`lib/wanderer_app/external_events/discord/corp_tickers.ex:172`), and +`WandererApp.Esi.MockBehaviour` does not declare these two functions +(`test/support/mock_definitions.ex:127-141`), so `Mox.stub(WandererApp.Esi.Mock, +:get_routes_custom, ...)` raises today. This step only adds the missing +callbacks to the behaviour and mock; the production call sites are changed in +Step 3, once a test exists that requires them. + +```elixir + defmodule WandererApp.Esi.MockBehaviour do + @callback get_character_info(binary()) :: {:ok, map()} | {:error, any()} + @callback get_character_info(binary(), keyword()) :: {:ok, map()} | {:error, any()} + @callback get_corporation_info(binary()) :: {:ok, map()} | {:error, any()} + @callback get_corporation_info(binary(), keyword()) :: {:ok, map()} | {:error, any()} + @callback get_alliance_info(binary()) :: {:ok, map()} | {:error, any()} + @callback get_alliance_info(binary(), keyword()) :: {:ok, map()} | {:error, any()} + @callback get_killmail(binary() | integer(), binary()) :: {:ok, map()} | {:error, any()} + + @callback get_killmail(binary() | integer(), binary(), keyword()) :: + {:ok, map()} | {:error, any()} + + @callback get_type_info(binary() | integer()) :: {:ok, map()} | {:error, any()} + @callback get_type_info(binary() | integer(), keyword()) :: {:ok, map()} | {:error, any()} + + @callback get_routes_custom([integer()], integer(), map()) :: {:ok, [map()]} | {:error, any()} + @callback get_routes_eve([integer()], integer(), map(), keyword()) :: {:ok, [map()]} + end +``` + +Run `mix compile` โ€” it succeeds (the behaviour changed, nothing implements it +yet, `Mox.defmock` regenerates from the behaviour automatically). + +- [ ] **Step 2: Write the failing tests** + +One file, three tests: the regression test for `find/5`'s existing fallback, +the new `find_strict/5` error path, and the shared-cache-key assertion the +spec's Testing section calls for explicitly. `async: false` because the ESI +seam added in Step 3 is application env read inside a function body (same +reason `CorpTickersTest` is `async: false`). + +```elixir +defmodule WandererApp.Map.RoutesFindStrictTest do + use WandererApp.DataCase, async: false + + import Mox + + alias WandererApp.Map.Routes + + setup :set_mox_from_context + setup :verify_on_exit! + + setup do + # `find/5` and `find_strict/5` share a cache key built from `{origin, hubs, + # params}` (map_routes.ex:224-225). Trig-system data feeds `params.avoid` + # (map_routes.ex:154-201), so priming it to `[]` keeps every test's params + # identical without a real `MapSolarSystem` row for the trig query. + WandererApp.Cache.insert(:trig_systems, []) + on_exit(fn -> WandererApp.Cache.delete(:trig_systems) end) + + original_esi = Application.get_env(:wanderer_app, :esi_client) + Application.put_env(:wanderer_app, :esi_client, WandererApp.Esi.Mock) + on_exit(fn -> Application.put_env(:wanderer_app, :esi_client, original_esi) end) + + :ok + end + + # `avoid_wormholes: true` in `routes_settings` skips the `MapConnection` read + # and the Thera chain fetch entirely (map_routes.ex:100-152), so these tests + # exercise the ESI seam without needing a real map's connections in the DB. + @routes_settings %{avoid_wormholes: true} + + defp unique_system_id, do: 30_000_000 + System.unique_integer([:positive]) + + # `get_system_static_info/1` reads `:system_static_info_cache` before falling + # back to a full `MapSolarSystem` table scan (cached_info.ex:101-141), and + # that fallback has no clause for `{:error, :not_found}` in `find/5`'s + # `Task.async_stream` handler (map_routes.ex:61-67) โ€” priming the cache + # avoids both the DB round trip and that latent crash. + defp stub_static_info(system_id) do + Cachex.put(:system_static_info_cache, system_id, %{ + solar_system_id: system_id, + security: "0.9", + system_class: 7 + }) + + on_exit(fn -> Cachex.del(:system_static_info_cache, system_id) end) + end + + test "find/5 still falls back to get_routes_eve on a custom-route error" do + hub = unique_system_id() + origin = unique_system_id() + stub_static_info(hub) + stub_static_info(origin) + + stub(WandererApp.Esi.Mock, :get_routes_custom, fn _hubs, _origin, _params -> + {:error, :solver_unreachable} + end) + + stub(WandererApp.Esi.Mock, :get_routes_eve, fn hubs, origin, _params, _opts -> + {:ok, + Enum.map(hubs, fn hub -> + %{"origin" => origin, "destination" => hub, "systems" => [], "success" => false} + end)} + end) + + assert {:ok, %{routes: [%{success: false}], systems_static_data: []}} = + Routes.find( + Ecto.UUID.generate(), + [Integer.to_string(hub)], + Integer.to_string(origin), + @routes_settings, + false + ) + end + + test "find_strict/5 propagates {:error, reason} instead of falling back to get_routes_eve" do + hub = unique_system_id() + origin = unique_system_id() + + stub(WandererApp.Esi.Mock, :get_routes_custom, fn _hubs, _origin, _params -> + {:error, :solver_unreachable} + end) + + stub(WandererApp.Esi.Mock, :get_routes_eve, fn _hubs, _origin, _params, _opts -> + flunk("find_strict/5 must not fall back to get_routes_eve on a solver error") + end) + + assert {:error, :solver_unreachable} = + Routes.find_strict( + Ecto.UUID.generate(), + [Integer.to_string(hub)], + Integer.to_string(origin), + @routes_settings, + false + ) + end + + test "find_strict/5 matches find/5 on the success path and shares its cache key" do + hub = unique_system_id() + origin = unique_system_id() + stub_static_info(hub) + stub_static_info(origin) + map_id = Ecto.UUID.generate() + + # `expect ... 1` proves the shared cache key: if `find_strict/5` hashed its + # params differently from `find/5`, the second call below would miss the + # cache and this expectation would fail with "called 2 times". + expect(WandererApp.Esi.Mock, :get_routes_custom, 1, fn hubs, origin, _params -> + {:ok, + Enum.map(hubs, fn hub -> + %{ + "origin" => origin, + "destination" => hub, + "systems" => [hub], + "success" => true + } + end)} + end) + + assert {:ok, strict_result} = + Routes.find_strict( + map_id, + [Integer.to_string(hub)], + Integer.to_string(origin), + @routes_settings, + false + ) + + assert {:ok, find_result} = + Routes.find( + map_id, + [Integer.to_string(hub)], + Integer.to_string(origin), + @routes_settings, + false + ) + + assert strict_result == find_result + assert [%{success: true, origin: ^origin, destination: ^hub}] = strict_result.routes + end +end +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `mix test test/unit/map/map_routes_find_strict_test.exs` +Expected: FAIL on every test with +`** (UndefinedFunctionError) function WandererApp.Map.Routes.find_strict/5 is undefined or private` +(the first test, exercising only `find/5`, is expected to already pass โ€” +confirm that in the output before continuing, since it is the regression +guard for this whole task). + +- [ ] **Step 4: Add the swappable ESI seam** + +Route both existing call sites through a private resolver, matching the +`esi_client/0` pattern in `corp_tickers.ex:172`. Defaults to the real +`WandererApp.Esi`, so every existing caller (nothing sets `:esi_client` outside +tests) is unaffected. + +```elixir + defp get_all_routes(hubs, origin, params, opts \\ []) do + cache_key = + "routes-#{origin}-#{hubs |> Enum.join("-")}-#{:crypto.hash(:sha, :erlang.term_to_binary(params))}" + + case WandererApp.Cache.lookup(cache_key) do + {:ok, result} when not is_nil(result) -> + {:ok, result} + + _ -> + case esi_client().get_routes_custom(hubs, origin, params) do + {:ok, result} -> + WandererApp.Cache.insert( + cache_key, + result, + ttl: @routes_ttl + ) + + {:ok, result} + + {:error, error} -> + error_file_path = save_error_params(origin, hubs, params) + + @logger.error( + "Error getting custom routes for #{inspect(origin)}: #{inspect(params)}. Params saved to: #{error_file_path}" + ) + + if Keyword.get(opts, :strict, false) do + {:error, error} + else + esi_client().get_routes_eve(hubs, origin, params, opts) + end + end + end + end + + defp esi_client, do: Application.get_env(:wanderer_app, :esi_client, WandererApp.Esi) +``` + +This replaces the existing `get_all_routes/4` body in place (same function, +same clause count) and the `defp save_error_params` clause immediately below +it is untouched. `opts` already reached `get_routes_eve/4` unchanged before +this edit (`map_routes.ex:249`); it now also carries the `:strict` flag, which +`get_routes_eve/4` ignores (its param list is `_opts`), so nothing downstream +needs to change. + +- [ ] **Step 5: Run test to verify the ESI seam is exercised** + +Run: `mix test test/unit/map/map_routes_find_strict_test.exs` +Expected: the two tests naming `get_routes_custom`/`get_routes_eve` now reach +the stub, but `find_strict/5` is still undefined โ€” FAIL with the same +`UndefinedFunctionError` as Step 3 on the two `find_strict` tests; the `find/5` +fallback regression test now PASSES (confirms the seam preserved existing +behavior before `find_strict/5` exists at all). + +- [ ] **Step 6: Extract the static-data hydration and add `do_find_routes/5` and `find_strict/5`** + +`find/5`'s hydration block (`map_routes.ex:53-77`) is identical to what +`find_strict/5` needs โ€” the design's stated intent is "keeping ... static-data +hydration in one place rather than duplicating them into the watcher" +(spec, "Distinguishing failure from no-route"). Extract it once, call it from +both. + +```elixir + def find(map_id, hubs, origin, routes_settings, false) do + case do_find_routes(map_id, origin, hubs, routes_settings) do + {:ok, routes} -> + {:ok, %{routes: routes, systems_static_data: hydrate_static_data(routes)}} + + _error -> + {:ok, %{routes: [], systems_static_data: []}} + end + end + + def find(_map_id, hubs, origin, _routes_settings, true) do + origin = origin |> String.to_integer() + hubs = hubs |> Enum.map(&(&1 |> String.to_integer())) + + routes = + hubs + |> Enum.map(fn hub -> + %{origin: origin, destination: hub, success: false, systems: [], has_connection: false} + end) + + {:ok, %{routes: routes, systems_static_data: []}} + end + + @doc """ + Sibling of `find/5` for callers that must distinguish a solver outage from a + genuine no-path result (see the design doc, "Distinguishing failure from + no-route"). Same params assembly, same cache key, same TTL โ€” the only + difference is that a `get_routes_custom/3` error is returned to the caller + instead of falling back to the `get_routes_eve/4` stub. + + The final argument is named `hubs_limit_reached?`. It is *not* "avoid + wormholes": as in `find/5`, `true` means "the hub count already exceeded the + map's limit, skip the solver" โ€” see `find/5`'s second clause + (`map_routes.ex:80-91`) and its callers in `map_routes_event_handler.ex:96,105`. + Route alerts always pass `false`. + """ + @spec find_strict(binary(), [binary()], binary(), map(), boolean()) :: + {:ok, %{routes: [map()], systems_static_data: [map() | nil]}} | {:error, term()} + def find_strict(map_id, hubs, origin, routes_settings, false) do + case do_find_routes(map_id, origin, hubs, routes_settings, strict: true) do + {:ok, routes} -> + {:ok, %{routes: routes, systems_static_data: hydrate_static_data(routes)}} + + {:error, _reason} = error -> + error + end + end + + def find_strict(_map_id, hubs, origin, _routes_settings, true) do + origin = origin |> String.to_integer() + hubs = hubs |> Enum.map(&(&1 |> String.to_integer())) + + routes = + hubs + |> Enum.map(fn hub -> + %{origin: origin, destination: hub, success: false, systems: [], has_connection: false} + end) + + {:ok, %{routes: routes, systems_static_data: []}} + end + + defp hydrate_static_data(routes) do + routes + |> Enum.map(fn route_info -> route_info.systems end) + |> List.flatten() + |> Enum.uniq() + |> Task.async_stream( + fn system_id -> + case WandererApp.CachedInfo.get_system_static_info(system_id) do + {:ok, nil} -> + nil + + {:ok, system} -> + system |> Map.take(@minimum_route_attrs) + end + end, + max_concurrency: System.schedulers_online() * 4 + ) + |> Enum.map(fn {:ok, val} -> val end) + end + + defp do_find_routes(map_id, origin, hubs, routes_settings, opts \\ []) do + origin = origin |> String.to_integer() + hubs = hubs |> Enum.map(&(&1 |> String.to_integer())) + + routes_settings = @default_routes_settings |> Map.merge(routes_settings) + + connections = + case routes_settings.avoid_wormholes do + false -> + map_chains = + routes_settings + |> Map.take(@get_link_pairs_advanced_params) + |> Map.put_new(:map_id, map_id) + |> WandererApp.Api.MapConnection.get_link_pairs_advanced!() + |> Enum.map(fn %{ + solar_system_source: solar_system_source, + solar_system_target: solar_system_target + } -> + %{ + first: solar_system_source, + second: solar_system_target + } + end) + |> Enum.uniq() + + {:ok, thera_chains} = + case routes_settings.include_thera do + true -> + WandererApp.Server.TheraDataFetcher.get_chain_pairs(routes_settings) + + false -> + {:ok, []} + end + + chains = remove_intersection([map_chains | thera_chains] |> List.flatten()) + + chains = + case routes_settings.include_cruise do + false -> + {:ok, wh_class_a_systems} = WandererApp.CachedInfo.get_wh_class_a_systems() + + chains + |> Enum.filter(fn x -> + not Enum.member?(wh_class_a_systems, x.first) and + not Enum.member?(wh_class_a_systems, x.second) + end) + + _ -> + chains + end + + chains + |> Enum.map(fn chain -> + ["#{chain.first}|#{chain.second}", "#{chain.second}|#{chain.first}"] + end) + |> List.flatten() + + true -> + [] + end + + {:ok, trig_systems} = WandererApp.CachedInfo.get_trig_systems() + + pochven_solar_systems = + trig_systems + |> Enum.filter(fn s -> s.triglavian_invasion_status == "Final" end) + |> Enum.map(& &1.solar_system_id) + + triglavian_solar_systems = + trig_systems + |> Enum.filter(fn s -> s.triglavian_invasion_status == "Triglavian" end) + |> Enum.map(& &1.solar_system_id) + + edencom_solar_systems = + trig_systems + |> Enum.filter(fn s -> s.triglavian_invasion_status == "Edencom" end) + |> Enum.map(& &1.solar_system_id) + + avoidance_list = + case routes_settings.avoid_edencom do + true -> + edencom_solar_systems + + false -> + [] + end + + avoidance_list = + case routes_settings.avoid_triglavian do + true -> + [avoidance_list | triglavian_solar_systems] + + false -> + avoidance_list + end + + avoidance_list = + case routes_settings.avoid_pochven do + true -> + [avoidance_list | pochven_solar_systems] + + false -> + avoidance_list + end + + avoidance_list = + (@default_avoid_systems ++ [routes_settings.avoid | avoidance_list]) + |> List.flatten() + |> Enum.uniq() + + params = + %{ + datasource: "tranquility", + flag: routes_settings.path_type, + connections: connections, + avoid: avoidance_list + } + + case get_all_routes(hubs, origin, params, opts) do + {:ok, all_routes} -> + routes = + all_routes + |> Enum.map(fn route_info -> + map_route_info(route_info) + end) + |> Enum.filter(fn route_info -> not is_nil(route_info) end) + + {:ok, routes} + + {:error, _reason} = error -> + error + end + end +``` + +This replaces `find/5` (both clauses), `do_find_routes/4`, and inserts +`find_strict/5` and `hydrate_static_data/1` immediately after. `do_find_routes/4` +becomes `do_find_routes/5` with a defaulted `opts \\ []`, so `find/5`'s +existing call `do_find_routes(map_id, origin, hubs, routes_settings)` still +compiles unchanged and still runs with `opts = []`, i.e. `strict: false` by +`Keyword.get/3`'s default in `get_all_routes/4` โ€” `find/5`'s behavior is +provably identical to before this task. + +- [ ] **Step 7: Run test to verify it passes** + +Run: `mix test test/unit/map/map_routes_find_strict_test.exs` +Expected: `3 tests, 0 failures`. + +- [ ] **Step 8: Run the broader map test suite for regressions** + +Run: `mix test test/unit/map/` +Expected: no new failures relative to the pre-task baseline (there is no +pre-existing `map_routes_test.exs`, so this is purely a check that the edit +did not break `test/unit/map/*` tests that exercise adjacent code, e.g. +`test/unit/map/map_scopes_test.exs`'s shared `:system_static_info_cache` +Cachex entries). + +- [ ] **Step 9: Format and commit** + +```bash +mix format lib/wanderer_app/map/map_routes.ex test/support/mock_definitions.ex test/unit/map/map_routes_find_strict_test.exs +git add lib/wanderer_app/map/map_routes.ex test/support/mock_definitions.ex test/unit/map/map_routes_find_strict_test.exs +git commit -m "feat(routes): add find_strict/5, distinguishing solver outage from no-path" +``` + +--- + +### Task 2: `WandererApp.Map.RouteAlert.Evaluator` + +**Files:** +- Create: `lib/wanderer_app/map/route_alert/evaluator.ex` +- Test: `test/unit/map/route_alert/evaluator_test.exs` + +**Interfaces:** +- Consumes: the `{:ok, %{routes: [route_entry()], systems_static_data: [map() | nil]}} | + {:error, term()}` shape `find_strict/5` produces (Task 1) โ€” passed in directly + as `solver_result`, never fetched by this module. No HTTP, no GenServer, no + `CachedInfo` call: every system's `security` and `system_class` travel inside + `systems_static_data`. +- Produces (per `00-contract.md`, Task 2): + ```elixir + @type outcome :: + {:qualifying, %{jumps: pos_integer(), path: [integer()], exit_system: integer() | nil}} + | :none + | :unknown + + @spec evaluate({:ok, map()} | {:error, term()}, keyword()) :: outcome() + def evaluate(solver_result, opts) # opts: [max_jumps: pos_integer()] + + @spec solver_settings() :: map() + @spec jita_system_id() :: 30_000_142 + @spec highsec_threshold() :: float() + ``` + +- [ ] **Step 1: Write the failing tests** + +All ten required cases in one file, since the module under test is pure and +the whole spec is small enough to write against a single fixture builder. + +```elixir +defmodule WandererApp.Map.RouteAlert.EvaluatorTest do + use ExUnit.Case, async: true + + alias WandererApp.Map.RouteAlert.Evaluator + + # 7 = k-space highsec (per `SystemClass`'s companion class ids in + # `map_scopes_test.exs:14`); 1 = a C1 wormhole; not in + # `SystemClass.wormhole_classes/0` is exactly what "non-wormhole" means here. + @hs_class 7 + @wh_class 1 + + defp static(id, security, class \\ @hs_class) do + %{solar_system_id: id, security: security, system_class: class} + end + + defp entry(origin, systems, success \\ true) do + %{origin: origin, systems: systems, destination: 30_000_142, success: success, has_connection: systems != []} + end + + defp solver_result(entries, static_data) do + {:ok, %{routes: entries, systems_static_data: static_data}} + end + + describe "evaluate/2 โ€” failure and no-path" do + test "{:error, _} is :unknown, regardless of opts" do + assert Evaluator.evaluate({:error, :timeout}, max_jumps: 5) == :unknown + end + + test "routes: [] is :unknown โ€” the solver returning nothing is not a decision" do + assert Evaluator.evaluate(solver_result([], []), max_jumps: 5) == :unknown + end + + test "every entry success: false is :none โ€” a genuine no-path" do + result = solver_result([entry(1, [], false)], []) + assert Evaluator.evaluate(result, max_jumps: 5) == :none + end + end + + describe "evaluate/2 โ€” wormhole exemption" do + test "a J-space hop at wormhole security does not disqualify the route" do + origin = 31_000_001 + exit = 30_000_100 + + static_data = [ + static(origin, -1.0, @wh_class), + static(exit, 0.9) + ] + + result = solver_result([entry(origin, [exit])], static_data) + + assert {:qualifying, %{jumps: 1, path: [^origin, ^exit], exit_system: ^exit}} = + Evaluator.evaluate(result, max_jumps: 5) + end + end + + describe "evaluate/2 โ€” the 0.45 boundary" do + # `entry/3`'s `has_connection` derives from `systems != []` + # (`map_route_info/1`, `map_routes.ex:333`), so every boundary case here + # needs a real hop rather than an empty `systems` list โ€” an empty list + # would make the entry itself `unsuccessful?/1` for the wrong reason and + # the test would pass without ever reaching the security check. + test "0.45 qualifies" do + origin = 30_000_001 + hop = 30_000_002 + static_data = [static(origin, 0.9), static(hop, 0.45)] + result = solver_result([entry(origin, [hop])], static_data) + + assert {:qualifying, %{jumps: 1}} = Evaluator.evaluate(result, max_jumps: 5) + end + + test "0.4 does not qualify" do + origin = 30_000_001 + hop = 30_000_002 + static_data = [static(origin, 0.9), static(hop, 0.4)] + result = solver_result([entry(origin, [hop])], static_data) + + assert Evaluator.evaluate(result, max_jumps: 5) == :none + end + end + + describe "evaluate/2 โ€” fail-closed on unresolved statics" do + test "a system missing from systems_static_data disqualifies the whole route" do + origin = 30_000_001 + hop = 30_000_002 + # `hop`'s static entry is absent entirely. + static_data = [static(origin, 0.9)] + + result = solver_result([entry(origin, [hop])], static_data) + + assert Evaluator.evaluate(result, max_jumps: 5) == :none + end + + test "a nil entry in systems_static_data is treated as absent, not crashed on" do + origin = 30_000_001 + hop = 30_000_002 + static_data = [static(origin, 0.9), nil, static(hop, 0.9)] + + result = solver_result([entry(origin, [hop])], static_data) + + assert {:qualifying, _} = Evaluator.evaluate(result, max_jumps: 5) + end + + test "an unparseable security string disqualifies the route" do + origin = 30_000_001 + hop = 30_000_002 + static_data = [static(origin, 0.9), static(hop, "not-a-number")] + + result = solver_result([entry(origin, [hop])], static_data) + + assert Evaluator.evaluate(result, max_jumps: 5) == :none + end + end + + describe "evaluate/2 โ€” jump counting" do + test "wormhole hops count toward the jump total" do + origin = 31_000_001 + wh_hop = 31_000_002 + exit = 30_000_100 + + static_data = [ + static(origin, -1.0, @wh_class), + static(wh_hop, -1.0, @wh_class), + static(exit, 0.9) + ] + + result = solver_result([entry(origin, [wh_hop, exit])], static_data) + + assert {:qualifying, %{jumps: 2}} = Evaluator.evaluate(result, max_jumps: 5) + end + + test "jumps == max_jumps qualifies" do + origin = 30_000_001 + hop = 30_000_002 + static_data = [static(origin, 0.9), static(hop, 0.9)] + + result = solver_result([entry(origin, [hop])], static_data) + + assert {:qualifying, %{jumps: 1}} = Evaluator.evaluate(result, max_jumps: 1) + end + + test "jumps == max_jumps + 1 does not qualify" do + origin = 30_000_001 + hop = 30_000_002 + static_data = [static(origin, 0.9), static(hop, 0.9)] + + result = solver_result([entry(origin, [hop])], static_data) + + assert Evaluator.evaluate(result, max_jumps: 0) == :none + end + end + + describe "evaluate/2 โ€” exit_system" do + test "exit_system is the first non-wormhole system on the path" do + origin = 31_000_001 + wh_hop = 31_000_002 + exit = 30_000_100 + hs_hop_after_exit = 30_000_101 + + static_data = [ + static(origin, -1.0, @wh_class), + static(wh_hop, -1.0, @wh_class), + static(exit, 0.9), + static(hs_hop_after_exit, 0.9) + ] + + result = solver_result([entry(origin, [wh_hop, exit, hs_hop_after_exit])], static_data) + + assert {:qualifying, %{exit_system: ^exit}} = Evaluator.evaluate(result, max_jumps: 5) + end + end + + describe "solver_settings/0, jita_system_id/0, highsec_threshold/0" do + test "returns the pinned settings from the design's decision 8" do + assert Evaluator.solver_settings() == %{ + include_eol: false, + include_mass_crit: false, + include_frig: false, + include_cruise: true, + avoid_pochven: true, + avoid_edencom: true, + avoid_triglavian: true, + include_thera: false + } + end + + test "jita_system_id/0 and highsec_threshold/0 are pinned constants" do + assert Evaluator.jita_system_id() == 30_000_142 + assert Evaluator.highsec_threshold() == 0.45 + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/map/route_alert/evaluator_test.exs` +Expected: FAIL with +`** (UndefinedFunctionError) function WandererApp.Map.RouteAlert.Evaluator.evaluate/2 is undefined (module WandererApp.Map.RouteAlert.Evaluator is not available)` +on every test (the module does not exist yet). + +- [ ] **Step 3: Implement the Evaluator** + +```elixir +defmodule WandererApp.Map.RouteAlert.Evaluator do + @moduledoc """ + Pure decision function turning a `WandererApp.Map.Routes.find_strict/5` + result into the three-state model the route-alert watcher acts on. No HTTP, + no GenServer, no `CachedInfo` lookups โ€” every system's security and class + travel in `systems_static_data`, which `find_strict/5` already hydrates. + + See the design doc's "Alert semantics" and "Failure posture" sections for the + rules this module encodes. + """ + + alias WandererApp.SystemClass + + @jita_system_id 30_000_142 + @highsec_threshold 0.45 + + # Derived at compile time from the canonical list rather than restated, so + # this cannot drift from `SystemClass`. A module attribute is required because + # `system_qualifies?/2` matches on the class in a guard, and a guard cannot + # call a remote function. + @wormhole_classes SystemClass.wormhole_classes() + + # Pinned per the design's decision 8 โ€” there is no user in this code path, so + # settings are not read from any widget preference. `include_mass_crit: + # false` and `include_frig: false` differ from `Routes`' own module defaults + # because a crit or frigate-sized connection will not pass a hauler. + # `include_thera: false` keeps every alert attributable to the map's own + # chain rather than to public Thera connectivity. + @solver_settings %{ + include_eol: false, + include_mass_crit: false, + include_frig: false, + include_cruise: true, + avoid_pochven: true, + avoid_edencom: true, + avoid_triglavian: true, + include_thera: false + } + + @type outcome :: + {:qualifying, %{jumps: pos_integer(), path: [integer()], exit_system: integer() | nil}} + | :none + | :unknown + + @spec jita_system_id() :: 30_000_142 + def jita_system_id, do: @jita_system_id + + @spec highsec_threshold() :: float() + def highsec_threshold, do: @highsec_threshold + + @spec solver_settings() :: map() + def solver_settings, do: @solver_settings + + @doc """ + `opts` must include `max_jumps: pos_integer()`. + + Fails closed: any system on a route's path that is missing from + `systems_static_data`, or whose `security` will not parse, disqualifies that + route entirely (`:none`, not `:unknown`) โ€” an unresolvable system is a route + this module will not vouch for. See the design doc's "Failure posture". + """ + @spec evaluate({:ok, map()} | {:error, term()}, keyword()) :: outcome() + def evaluate({:error, _reason}, _opts), do: :unknown + + def evaluate({:ok, %{routes: []}}, _opts), do: :unknown + + def evaluate({:ok, %{routes: entries, systems_static_data: static_data}}, opts) do + if Enum.all?(entries, &unsuccessful?/1) do + :none + else + max_jumps = Keyword.fetch!(opts, :max_jumps) + static_by_id = index_static_data(static_data) + + entries + |> Enum.reject(&unsuccessful?/1) + |> Enum.find_value(:none, &qualify(&1, static_by_id, max_jumps)) + end + end + + defp unsuccessful?(%{success: false}), do: true + defp unsuccessful?(%{has_connection: false}), do: true + defp unsuccessful?(_entry), do: false + + defp qualify(entry, static_by_id, max_jumps) do + path = [entry.origin | entry.systems] + jumps = length(entry.systems) + + if jumps <= max_jumps and path_qualifies?(path, static_by_id) do + {:qualifying, + %{jumps: jumps, path: path, exit_system: find_exit_system(path, static_by_id)}} + end + end + + # `Enum.all?/2` short-circuits on the first disqualifying hop, which is also + # the fail-closed behavior: an unresolvable or wormhole-failing hop stops the + # check rather than being skipped. + defp path_qualifies?(path, static_by_id) do + Enum.all?(path, &system_qualifies?(&1, static_by_id)) + end + + defp system_qualifies?(system_id, static_by_id) do + case Map.fetch(static_by_id, system_id) do + :error -> + false + + {:ok, %{system_class: class}} when class in @wormhole_classes -> + true + + {:ok, %{security: security}} -> + case parse_security(security) do + {:ok, value} -> value >= @highsec_threshold + {:error, _reason} -> false + end + end + end + + defp find_exit_system(path, static_by_id) do + Enum.find(path, fn system_id -> + case Map.fetch(static_by_id, system_id) do + {:ok, %{system_class: class}} -> not SystemClass.wormhole?(class) + :error -> false + end + end) + end + + defp index_static_data(static_data) do + static_data + |> Enum.reject(&is_nil/1) + |> Map.new(&{&1.solar_system_id, &1}) + end + + # Duplicated from `RouteBuilderClient.parse_security/1` (`route_builder_client.ex:200-210`) + # rather than reused: that function is private, and this module's threshold + # deliberately diverges from it (0.45 here vs. 0.5 there โ€” see + # `highsec_threshold/0`'s moduledoc reference and the design doc's decision + # 4), so sharing the parser without sharing the threshold would leave the one + # place that says "0.5" sitting next to the one place that says "0.45" with + # no visible link between them. + defp parse_security(security) when is_float(security), do: {:ok, security} + defp parse_security(security) when is_integer(security), do: {:ok, security * 1.0} + + defp parse_security(security) when is_binary(security) do + case Float.parse(security) do + {value, _rest} -> {:ok, value} + :error -> {:error, :invalid_security} + end + end + + defp parse_security(_security), do: {:error, :invalid_security} +end +``` + +Two places test the wormhole exemption, and neither restates the class list. +`system_qualifies?/2` matches in a **guard**, which cannot call a remote +function, so it uses `@wormhole_classes` โ€” a module attribute evaluated at +compile time from `SystemClass.wormhole_classes/0`. `find_exit_system/2` is not +guard-constrained and calls `SystemClass.wormhole?/1` directly. `SystemClass` +stays the single source of truth in both. + +One consequence to know: because `@wormhole_classes` is resolved at compile +time, adding a class to `SystemClass` requires `Evaluator` to be recompiled. +Elixir's compiler tracks this dependency and does so automatically on a normal +`mix compile`; it matters only if someone hot-loads `SystemClass` alone. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/map/route_alert/evaluator_test.exs` +Expected: `15 tests, 0 failures`. + +- [ ] **Step 5: Format and commit** + +```bash +mix format lib/wanderer_app/map/route_alert/evaluator.ex test/unit/map/route_alert/evaluator_test.exs +git add lib/wanderer_app/map/route_alert/evaluator.ex test/unit/map/route_alert/evaluator_test.exs +git commit -m "feat(route-alert): add Evaluator, the pure jump/security decision function" +``` + +--- + +### Task 3: Ash schema โ€” route alert config and mention targets + +**Files:** +- Modify: `lib/wanderer_app/api/map_discord_notification.ex` +- Modify: `lib/wanderer_app/api/map_discord_webhook.ex` +- Test: `test/unit/api/map_discord_notification_test.exs` +- Test: `test/unit/api/map_discord_webhook_test.exs` +- Generated (via `mix ash.codegen`, inspect and commit): one migration under + `priv/repo/migrations/` for `map_discord_notifications_v1`, one for + `map_discord_webhooks_v1` + +**Interfaces:** +- Consumes: nothing new โ€” builds on the existing `MapDiscordNotification` / + `MapDiscordWebhook` resources and their `after_transaction` invalidation + hooks. +- Produces: + - `MapDiscordNotification` attributes `route_alerts_enabled?`, + `home_system_id`, `route_max_jumps` โ€” the exact shape Task 7's + `RouteWatcher` and Task 5's `Router` read. + - `MapDiscordWebhook` attribute `mention_targets` and widened `role` + `one_of: [:system, :character, :route]` โ€” consumed by Task 5's + `route_destination/1` and Task 6's `format_route_alert/2` (via + `opts[:mention_targets]`). + +Note on scope: `mention_targets` gets its own regex-matching validation +module here (`ValidateMentionTargets`), carrying its own copy of the +`^(user|role):\d{17,20}$` pattern so that this task does not depend on +Task 4's `Discord.Mentions` existing first. **This copy is temporary by +ruling:** Task 4's section 4.4 rewrites `ValidateMentionTargets` to delegate +to `Mentions.valid_target?/1` and deletes the literal here. Write it as shown +below anyway โ€” Task 4 owns the fold, and the rejection tests you write in this +task are what prove the fold preserved behaviour. + +--- + +#### 3.1 โ€” `route_alerts_enabled?`, `home_system_id`, `route_max_jumps` + +- [ ] **Step 1: Write the failing test** + +Add to `test/unit/api/map_discord_notification_test.exs`, inside the existing +`describe`-less body (it has none โ€” top-level `test`s), just before the final +`end`: + +```elixir + test "route alert fields default off with a 5-jump cap", %{map: map} do + assert {:ok, rec} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert rec.route_alerts_enabled? == false + assert rec.home_system_id == nil + assert rec.route_max_jumps == 5 + end + + test "route alert config round-trips through update", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, updated} = + MapDiscordNotification.update(rec, %{ + route_alerts_enabled?: true, + home_system_id: 30_000_142, + route_max_jumps: 3 + }) + + assert updated.route_alerts_enabled? == true + assert updated.home_system_id == 30_000_142 + assert updated.route_max_jumps == 3 + + assert {:ok, reloaded} = MapDiscordNotification.by_map(map.id) + assert reloaded.home_system_id == 30_000_142 + end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/api/map_discord_notification_test.exs -k "route alert fields default off"` +Expected: FAIL โ€” `route_alerts_enabled?` is not accepted by `:create` (or the +key is dropped and `Map.get(rec, :route_alerts_enabled?)` raises +`KeyError`/`Ash.Error.Invalid` for unknown input), since the attribute does +not exist on the resource yet. + +- [ ] **Step 3: Add the attributes and thread them through `accept`** + +In `lib/wanderer_app/api/map_discord_notification.ex`: + +```elixir + default_accept [ + :map_id, + :enabled?, + :wh_only, + :excluded_systems, + :focus_corp_ids, + :route_alerts_enabled?, + :home_system_id, + :route_max_jumps + ] +``` + +```elixir + update :update do + primary? true + require_atomic? false + + # Explicit, so `default_accept` cannot expose `:map_id`: re-parenting a + # notification would move it and its webhook children to another map. + # The three route fields ARE deliberately in this list โ€” unlike + # `:map_id` there is no re-parenting risk, and route alert config is + # meant to be editable the same way the kill-switch fields are. + accept [ + :enabled?, + :wh_only, + :excluded_systems, + :focus_corp_ids, + :route_alerts_enabled?, + :home_system_id, + :route_max_jumps + ] + + change after_transaction(&__MODULE__.invalidate_cache/3) + end +``` + +```elixir + attributes do + uuid_primary_key :id + + attribute :enabled?, :boolean, default: true, allow_nil?: false + attribute :wh_only, :boolean, default: true, allow_nil?: false + + attribute :excluded_systems, {:array, :integer} do + default [] + allow_nil? false + end + + attribute :focus_corp_ids, {:array, :integer} do + default [] + allow_nil? false + end + + # Route alerts โ€” separate switch from `enabled?`, which gates kills. Ships + # off: an operator must opt a map in, not discover it firing unannounced. + attribute :route_alerts_enabled?, :boolean, default: false, allow_nil?: false + + # No "home system" concept exists anywhere else in the codebase (see the + # design doc's repository-evidence table) โ€” this is where it is defined, + # scoped to this feature. Nullable: a map with route alerts off need not + # have one set, and `validate_home_system_required/2` below is what + # enforces the combination that matters. + attribute :home_system_id, :integer + + # Inclusive upper bound (design decision 5): "less than 6 jumps" means + # "at most 5", so the stored number and the UI copy agree. + attribute :route_max_jumps, :integer do + default 5 + allow_nil? false + # 1 is the trivial floor (a route of zero jumps is "already there", not + # an alert). 20 is a generous ceiling: it is nowhere near a real hauling + # route in this feature's wormhole-plus-highsec shape, but it stops a + # typo (e.g. an extra digit) from asking the solver to treat every + # multi-region path as "qualifying" and firing constantly. + constraints min: 1, max: 20 + end + + create_timestamp :inserted_at + update_timestamp :updated_at + end +``` + +- [ ] **Step 4: Generate and inspect the migration, then run it** + +Run: `mix ash.codegen add_route_alert_config` +This writes a new file under `priv/repo/migrations/` (timestamp-prefixed, +e.g. `_add_route_alert_config.exs`). Open it and confirm it adds exactly +three columns to `map_discord_notifications_v1`: +`route_alerts_enabled? boolean not null default false`, +`home_system_id bigint` (nullable), `route_max_jumps bigint not null default 5` +โ€” and nothing else. `route_max_jumps`'s `min`/`max` constraints are +Ash-side only; they do not appear as a DB `CHECK` constraint, so do not +expect one in the generated file. + +Run: `mix ash.migrate` +Expected: migration applies cleanly against the dev/test database. + +Run: `mix test test/unit/api/map_discord_notification_test.exs -k "route alert"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/wanderer_app/api/map_discord_notification.ex \ + test/unit/api/map_discord_notification_test.exs \ + priv/repo/migrations/*_add_route_alert_config.exs +git commit -m "feat(discord): add route alert config to MapDiscordNotification" +``` + +--- + +#### 3.2 โ€” `home_system_id` required when `route_alerts_enabled?` is true + +- [ ] **Step 1: Write the failing test** + +```elixir + test "route_alerts_enabled? without a home_system_id is rejected", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + MapDiscordNotification.update(rec, %{route_alerts_enabled?: true}) + + assert Enum.any?(errors, fn e -> + Map.get(e, :field) == :home_system_id and + to_string(Map.get(e, :message, "")) =~ "required" + end) + end + + test "route_alerts_enabled? with a home_system_id already set is accepted", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + {:ok, rec} = MapDiscordNotification.update(rec, %{home_system_id: 30_000_142}) + + assert {:ok, updated} = MapDiscordNotification.update(rec, %{route_alerts_enabled?: true}) + assert updated.route_alerts_enabled? == true + end + + test "home_system_id can be set while route_alerts_enabled? stays false", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, updated} = MapDiscordNotification.update(rec, %{home_system_id: 30_000_142}) + assert updated.route_alerts_enabled? == false + end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/api/map_discord_notification_test.exs -k "route_alerts_enabled?"` +Expected: FAIL on the first new test โ€” the update currently succeeds with no +validation, so `{:error, ...}` is never returned. + +- [ ] **Step 3: Add the validation** + +```elixir + validations do + validate &__MODULE__.validate_home_system_required/2 + end +``` + +Placed after `code_interface do ... end` and before `actions do ... end`, or +directly after `actions do ... end` โ€” either is valid Spark DSL ordering; +match the file's existing top-to-bottom order of `code_interface`, `actions`, +then this new `validations` block, then `attributes`. + +```elixir + @doc false + def validate_home_system_required(changeset, _context) do + # get_attribute/2 reads the value the changeset WOULD produce โ€” the new + # value if it is being set, otherwise the record's current one โ€” so this + # catches both "enable with no home system yet" and "clear the home + # system while alerts are still on" in one check. + enabled? = Ash.Changeset.get_attribute(changeset, :route_alerts_enabled?) + home_system_id = Ash.Changeset.get_attribute(changeset, :home_system_id) + + if enabled? && is_nil(home_system_id) do + {:error, + field: :home_system_id, + message: "is required when route alerts are enabled"} + else + :ok + end + end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/api/map_discord_notification_test.exs -k "route_alerts_enabled? OR home_system_id"` +Expected: PASS, all three. + +- [ ] **Step 5: Commit** + +```bash +git add lib/wanderer_app/api/map_discord_notification.ex \ + test/unit/api/map_discord_notification_test.exs +git commit -m "feat(discord): require home_system_id when route alerts are enabled" +``` + +--- + +#### 3.3 โ€” `route_max_jumps` bounds + +- [ ] **Step 1: Write the failing test** + +```elixir + test "route_max_jumps accepts the 1..20 boundary and rejects outside it", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, _} = MapDiscordNotification.update(rec, %{route_max_jumps: 1}) + assert {:ok, _} = MapDiscordNotification.update(rec, %{route_max_jumps: 20}) + assert {:error, _} = MapDiscordNotification.update(rec, %{route_max_jumps: 0}) + assert {:error, _} = MapDiscordNotification.update(rec, %{route_max_jumps: 21}) + end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/api/map_discord_notification_test.exs -k "route_max_jumps accepts"` +Expected: FAIL โ€” this test is written against the `constraints min: 1, max: 20` +added in step 3.1's Step 3. If 3.1 has already landed, this passes +immediately; write and run it anyway as a standalone regression guard before +assuming so, since a future refactor of that attribute is exactly what this +guards against. + +- [ ] **Step 3: Confirm or add the constraint** + +No production change expected if 3.1 already added +`constraints min: 1, max: 20` to `route_max_jumps`. If this task is being +done out of order, add it now (see the attribute block in 3.1 Step 3). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/api/map_discord_notification_test.exs -k "route_max_jumps accepts"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add test/unit/api/map_discord_notification_test.exs +git commit -m "test(discord): pin route_max_jumps bounds at 1..20" +``` + +--- + +#### 3.4 โ€” `MapDiscordWebhook`: `:route` role and `mention_targets` + +- [ ] **Step 1: Write the failing test** + +Add to `test/unit/api/map_discord_webhook_test.exs`, before the final `end`: + +```elixir + test "accepts the :route role", %{notification: notification} do + assert {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: valid_url() + }) + + assert hook.role == :route + assert hook.mention_targets == [] + end + + test "mention_targets accepts well-formed user and role snowflakes", %{ + notification: notification + } do + assert {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: valid_url(), + mention_targets: ["user:123456789012345678", "role:98765432109876543"] + }) + + assert hook.mention_targets == ["user:123456789012345678", "role:98765432109876543"] + end + + test "mention_targets rejects a handle, a bare id, and an out-of-range snowflake", %{ + notification: notification + } do + for bad <- ["@guarzo", "user:123", "role:123456789012345678901", "corp:123456789012345678"] do + assert {:error, %Ash.Error.Invalid{}} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: valid_url(), + mention_targets: [bad] + }), + "expected #{inspect(bad)} to be rejected" + end + end + + test "mention_targets round-trips through update", %{notification: notification} do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: valid_url() + }) + + assert {:ok, updated} = + MapDiscordWebhook.update(hook, %{mention_targets: ["role:112233445566778899"]}) + + assert updated.mention_targets == ["role:112233445566778899"] + end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/api/map_discord_webhook_test.exs -k "route OR mention_targets"` +Expected: FAIL โ€” `:route` is rejected by the current `one_of` constraint, and +`mention_targets` is not a recognized input at all. + +- [ ] **Step 3: Widen `role`, add `mention_targets`, wire the validation** + +```elixir + attribute :role, :atom do + allow_nil? false + constraints one_of: [:system, :character, :route] + end +``` + +```elixir + # Guild-scoped snowflakes to ping on this destination โ€” see the design + # doc's "Where configured targets live": these belong on the webhook row, + # not anywhere map- or instance-wide, because a role/user id from one + # guild is meaningless (and unrenderable) in another. + attribute :mention_targets, {:array, :string} do + default [] + allow_nil? false + end +``` + +```elixir + default_accept [:notification_id, :role, :webhook_url, :enabled?, :mention_targets] +``` + +```elixir + create :create do + primary? true + validate {__MODULE__.ValidateWebhookUrl, []} + validate {__MODULE__.ValidateMentionTargets, []} + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + update :update do + primary? true + require_atomic? false + # `mention_targets` is safe to add here unlike `notification_id`/`role`: + # it carries no ownership semantics, only which snowflakes this + # destination pings. + accept [:webhook_url, :enabled?, :mention_targets] + validate {__MODULE__.ValidateWebhookUrl, []} + validate {__MODULE__.ValidateMentionTargets, []} + change after_transaction(&__MODULE__.invalidate_cache/3) + end +``` + +```elixir + defmodule ValidateMentionTargets do + @moduledoc false + use Ash.Resource.Validation + + # Guild snowflakes are 17-20 decimal digits. Parseable, renderable + # (`<@id>` / `<@&id>`), and unable to hold a handle that would silently + # fail to ping โ€” see the design doc's "Mentions" section. + @target_regex ~r/^(user|role):\d{17,20}$/ + + @impl true + def validate(changeset, _opts, _context) do + case Ash.Changeset.get_argument_or_attribute(changeset, :mention_targets) do + nil -> + :ok + + targets when is_list(targets) -> + if Enum.all?(targets, &valid?/1) do + :ok + else + {:error, + field: :mention_targets, + message: "each entry must match user: or role: (17-20 digit snowflake)"} + end + + _ -> + :ok + end + end + + defp valid?(target) when is_binary(target), do: Regex.match?(@target_regex, target) + defp valid?(_), do: false + end +``` + +Add `ValidateMentionTargets` as a sibling of `ValidateWebhookUrl`, after that +module's closing `end`. + +- [ ] **Step 4: Generate and inspect the migration for `mention_targets`, then run it** + +Run: `mix ash.codegen add_webhook_mention_targets` +Confirm the generated file under `priv/repo/migrations/` adds exactly one +column, `mention_targets text[] not null default '{}'`, to +`map_discord_webhooks_v1`. The `role` enum widening must produce **no** +migration โ€” `role` is stored as `:text` (`map_discord_webhook.ex:199` before +this change), so `one_of` is an Ash-side constraint only. If the generated +diff includes anything touching `role`, stop and investigate before +committing; that would mean `role` is not actually a plain `:text` column in +this schema and the design's "no migration needed" claim was wrong. + +Run: `mix ash.migrate` + +Run: `mix test test/unit/api/map_discord_webhook_test.exs -k "route OR mention_targets"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/wanderer_app/api/map_discord_webhook.ex \ + test/unit/api/map_discord_webhook_test.exs \ + priv/repo/migrations/*_add_webhook_mention_targets.exs +git commit -m "feat(discord): add :route webhook role and mention_targets" +``` + +--- + +#### 3.5 โ€” Config changes still invalidate the dispatcher cache + +- [ ] **Step 1: Write the test** + +```elixir + test "updating route alert config invalidates the cache after the transaction, not inside it", + %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, _rec} = + rec + |> Ash.Changeset.for_update(:update, %{ + route_alerts_enabled?: true, + home_system_id: 30_000_142 + }) + |> cache_none_inside_transaction(map.id) + |> Ash.update() + + assert Cachex.get(@cache, map.id) == {:ok, nil} + end +``` + +Append this next to the existing "update invalidates the cache..." test, +reusing the file's `cache_none_inside_transaction/2` helper already defined +above it. + +- [ ] **Step 2: Run test** + +Run: `mix test test/unit/api/map_discord_notification_test.exs -k "route alert config invalidates"` +Expected: PASS immediately, with no production change โ€” `:update` already +carries `change after_transaction(&__MODULE__.invalidate_cache/3)` +unconditionally, and 3.1's Step 3 only added attributes to that same +action's `accept` list. This test is a regression guard: it fails only if a +future change splits route-alert fields onto a separate action that forgets +the hook, exactly the mistake the moduledoc at `map_discord_notification.ex:147-164` +warns against. + +- [ ] **Step 3: Commit** + +```bash +git add test/unit/api/map_discord_notification_test.exs +git commit -m "test(discord): route alert config changes invalidate the dispatcher cache" +``` + +--- + +### Task 4: Mentions โ€” env kill-switch, rendering, `allowed_mentions` hardening, and the regex fold + +**Files:** +- Modify: `lib/wanderer_app/env.ex` +- Modify: `lib/wanderer_app/external_events/discord/worker.ex` +- Modify: `lib/wanderer_app/api/map_discord_webhook.ex` (section 4.4 only โ€” rewrite `ValidateMentionTargets` to delegate; **do not** touch the accept lists, which Task 3 owns) +- Create: `lib/wanderer_app/external_events/discord/mentions.ex` +- Test: `test/unit/env_discord_mentions_test.exs` (new) +- Test: `test/unit/external_events/discord/mentions_test.exs` (new) +- Test: `test/unit/external_events/discord/worker_test.exs` (append) +- Test: `test/unit/api/map_discord_webhook_test.exs` (append, section 4.4 only) + +**Interfaces:** +- Consumes: `MapDiscordWebhook.mention_targets` (Task 3), the existing + `Worker`/`HttpClient` delivery path (`worker.ex`, `http_client.ex`). +- Produces: + - `WandererApp.Env.discord_mentions_enabled?/0` + - `WandererApp.ExternalEvents.Discord.Mentions.prefix/1`, + `.allowed_mentions/1`, `.valid_target?/1` โ€” consumed by Task 6's + `format_route_alert/2` for the "ping on open only" `content` prefix, and + by Task 3's `ValidateMentionTargets`, which section 4.4 below rewrites to + delegate here (it ships with its own regex copy in Task 3 so that task can + land standalone). + - A hardened `Worker` that attaches `allowed_mentions` to any outgoing + message carrying `"content"` that does not already have one. + +Scope note, from the design doc's "Mention injection is a real risk": as of +this task, `allowed_mentions` appears **nowhere** in `lib/` or `test/`. That +is a latent gap, not a live vulnerability โ€” the only three `"content"` +writers today are the static overflow string +(`embed_formatter.ex:133`), the static test message +(`discord_dispatcher.ex:146`), and `VoiceParticipants`-built mentions from +guild data. None of that is user-controlled text, and Discord does not fire +notifications for mentions inside embeds, so nothing is exploitable as +shipped. This task closes the gap anyway, because it is one careless +formatter change away from mattering and because Task 6 is about to add a +second, genuinely dynamic `"content"` writer (the route-alert ping prefix). + +--- + +#### 4.1 โ€” `Env.discord_mentions_enabled?/0` + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/env_discord_mentions_test.exs`: + +```elixir +defmodule WandererApp.EnvDiscordMentionsTest do + # async: false โ€” mutates the :external_events application env that other + # test files also override. + use ExUnit.Case, async: false + + alias WandererApp.Env + + setup do + original = Application.get_env(:wanderer_app, :external_events, []) + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + %{original: original} + end + + test "on by default", %{original: original} do + Application.put_env(:wanderer_app, :external_events, original) + assert Env.discord_mentions_enabled?() + end + + test "can be switched off as an incident kill-switch", %{original: original} do + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :discord_mentions_enabled, false) + ) + + refute Env.discord_mentions_enabled?() + end + + test "explicit true is still on", %{original: original} do + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :discord_mentions_enabled, true) + ) + + assert Env.discord_mentions_enabled?() + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/env_discord_mentions_test.exs` +Expected: FAIL with `UndefinedFunctionError` โ€” `discord_mentions_enabled?/0` +does not exist yet. + +- [ ] **Step 3: Add the function** + +In `lib/wanderer_app/env.ex`, placed near `corp_tickers_enabled?/0` since it +follows the same shape: + +```elixir + @doc """ + Whether Discord messages may carry role/user pings via `allowed_mentions`. + + On by default, unlike `notable_items_enabled?/0`: mentions are already a + per-map, per-webhook opt-in (`MapDiscordWebhook.mention_targets`), so an + instance with nothing configured pings nobody regardless of this flag. + This exists purely as an incident kill-switch โ€” an operator who needs + every mention silenced immediately (a runaway role ping, a compromised + mention target) flips this without touching per-map config or waiting for + a deploy. + """ + def discord_mentions_enabled?() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_mentions_enabled, true) + end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/env_discord_mentions_test.exs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/wanderer_app/env.ex test/unit/env_discord_mentions_test.exs +git commit -m "feat(discord): add discord_mentions_enabled? incident kill-switch" +``` + +--- + +#### 4.2 โ€” `Discord.Mentions` + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/external_events/discord/mentions_test.exs`: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.MentionsTest do + use ExUnit.Case, async: true + + alias WandererApp.ExternalEvents.Discord.Mentions + + describe "prefix/1" do + test "renders a user target" do + assert Mentions.prefix(["user:123456789012345678"]) == "<@123456789012345678>" + end + + test "renders a role target" do + assert Mentions.prefix(["role:987654321098765432"]) == "<@&987654321098765432>" + end + + test "joins multiple targets with a space, in order" do + assert Mentions.prefix(["user:111111111111111111", "role:222222222222222222"]) == + "<@111111111111111111> <@&222222222222222222>" + end + + test "empty list is nil" do + assert Mentions.prefix([]) == nil + end + end + + describe "allowed_mentions/1" do + test "empty targets still has parse: [] and empty lists" do + assert Mentions.allowed_mentions([]) == %{"parse" => [], "users" => [], "roles" => []} + end + + test "lists users and roles separately" do + assert Mentions.allowed_mentions([ + "user:111111111111111111", + "role:222222222222222222", + "user:333333333333333333" + ]) == %{ + "parse" => [], + "users" => ["111111111111111111", "333333333333333333"], + "roles" => ["222222222222222222"] + } + end + end + + describe "valid_target?/1" do + test "accepts well-formed user and role snowflakes" do + assert Mentions.valid_target?("user:12345678901234567") + assert Mentions.valid_target?("role:12345678901234567890") + end + + test "rejects a handle, a bare id, an unknown prefix, and out-of-range lengths" do + refute Mentions.valid_target?("@guarzo") + refute Mentions.valid_target?("123456789012345678") + refute Mentions.valid_target?("corp:123456789012345678") + refute Mentions.valid_target?("user:123") + refute Mentions.valid_target?("role:123456789012345678901") + end + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/external_events/discord/mentions_test.exs` +Expected: FAIL โ€” `WandererApp.ExternalEvents.Discord.Mentions` does not +exist. + +- [ ] **Step 3: Create the module** + +Create `lib/wanderer_app/external_events/discord/mentions.ex`: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.Mentions do + @moduledoc """ + Renders configured `MapDiscordWebhook.mention_targets` into Discord's two + mention mechanisms: a `content` prefix that actually pings, and the + `allowed_mentions` allowlist that makes doing so safe. + + Deliberately does not observe anything โ€” no voice state, no map presence. + Targets come only from what a map operator configured; see the design + doc's "Why not VoiceParticipants". + """ + + # Guild snowflakes are 17-20 decimal digits โ€” matches + # `MapDiscordWebhook.ValidateMentionTargets`. Kept as a separate literal + # here rather than a shared reference so this module has no compile-time + # dependency on the Ash resource. + @target_regex ~r/^(user|role):(\d{17,20})$/ + + @doc """ + Whether `target` is a well-formed `"user:"` or `"role:"` mention + target. Exposed so callers (and the resource-side validation) can check a + single value without going through the list-shaped functions below. + """ + @spec valid_target?(String.t()) :: boolean() + def valid_target?(target) when is_binary(target), do: Regex.match?(@target_regex, target) + def valid_target?(_), do: false + + @doc """ + Renders `targets` into a `content` prefix: `"user:123"` -> `"<@123>"`, + `"role:456"` -> `"<@&456>"`, joined by spaces. `[]` -> `nil`, so callers can + feed this straight to `VoiceParticipants.prepend_to_messages/2`, whose + no-prefix case is also `nil`. Any entry that fails `valid_target?/1` is + silently dropped rather than raising โ€” malformed data should never turn + into a delivery failure. + """ + @spec prefix([String.t()]) :: String.t() | nil + def prefix([]), do: nil + + def prefix(targets) do + targets + |> Enum.map(&render/1) + |> Enum.reject(&is_nil/1) + |> case do + [] -> nil + rendered -> Enum.join(rendered, " ") + end + end + + @doc """ + Builds the `allowed_mentions` object for a Discord message body. ALWAYS + includes `"parse" => []`, even for `[]` โ€” an empty allowlist with no parse + modes is what makes an unconfigured map safe to post to (see the design + doc's "Mention injection is a real risk"). Invalid entries are dropped, the + same as `prefix/1`. + """ + @spec allowed_mentions([String.t()]) :: map() + def allowed_mentions(targets) do + {users, roles} = + targets + |> Enum.filter(&valid_target?/1) + |> Enum.reduce({[], []}, fn target, {users, roles} -> + case String.split(target, ":", parts: 2) do + ["user", id] -> {[id | users], roles} + ["role", id] -> {users, [id | roles]} + end + end) + + %{"parse" => [], "users" => Enum.reverse(users), "roles" => Enum.reverse(roles)} + end + + defp render(target) do + case Regex.run(@target_regex, target) do + [_, "user", id] -> "<@#{id}>" + [_, "role", id] -> "<@&#{id}>" + _ -> nil + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/external_events/discord/mentions_test.exs` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/wanderer_app/external_events/discord/mentions.ex \ + test/unit/external_events/discord/mentions_test.exs +git commit -m "feat(discord): add Discord.Mentions rendering module" +``` + +--- + +#### 4.3 โ€” Harden `Worker` so every `content`-carrying message ships `allowed_mentions` + +- [ ] **Step 1: Write the failing test** + +Append to `test/unit/external_events/discord/worker_test.exs`, near the other +delivery tests (it already has `message/0`, `HttpStub`, `wait_for_requests/1` +in scope): + +```elixir + describe "allowed_mentions hardening" do + test "a message with content but no allowed_mentions gets a safe default attached", %{ + system: w + } do + WorkerSupervisor.deliver(w.id, [%{"content" => "test message"}]) + + assert [{_url, body}] = wait_for_requests(1) + assert body["allowed_mentions"] == %{"parse" => [], "users" => [], "roles" => []} + end + + test "an explicit allowed_mentions is left untouched", %{system: w} do + explicit = %{"parse" => [], "users" => ["111111111111111111"], "roles" => []} + + WorkerSupervisor.deliver(w.id, [ + %{"content" => "<@111111111111111111> route opened", "allowed_mentions" => explicit} + ]) + + assert [{_url, body}] = wait_for_requests(1) + assert body["allowed_mentions"] == explicit + end + + test "an embed-only message with no content is left without allowed_mentions", %{system: w} do + WorkerSupervisor.deliver(w.id, [message()]) + + assert [{_url, body}] = wait_for_requests(1) + refute Map.has_key?(body, "allowed_mentions") + end + end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/external_events/discord/worker_test.exs -k "allowed_mentions hardening"` +Expected: FAIL โ€” the first test fails because no `allowed_mentions` key is +added today; the other two currently pass by coincidence (nothing strips or +touches the key), so run them anyway to make sure they still hold once the +fix lands. + +- [ ] **Step 3: Attach `allowed_mentions` at the single send point** + +`worker.ex`'s `do_post/2` is the one place every outgoing message reaches +`HttpClient.post/2`, regardless of which caller built it โ€” the test message, +the kill embeds, a voice-mention prefix, or (once Task 6 lands) a route +alert. Fixing it here hardens all of them at once, per the design doc's +"Mentions" section. + +In `lib/wanderer_app/external_events/discord/worker.ex`: + +```elixir + alias WandererApp.Api.MapDiscordWebhook + alias WandererApp.ExternalEvents.Discord.HttpClient + alias WandererApp.ExternalEvents.Discord.Mentions +``` + +```elixir + defp do_post(%{current: current} = state, webhook) do + [message | _rest] = current.pending + message = attach_allowed_mentions(message) + url = webhook.webhook_url + + task = + Task.Supervisor.async_nolink( + WandererApp.ExternalEvents.Discord.TaskSupervisor, + fn -> HttpClient.post(url, message) end + ) + + put_current(state, %{current | task_ref: task.ref}) + end + + # Every message that carries `"content"` must also carry `allowed_mentions`, + # or Discord defaults to parsing @everyone/@here/user/role mentions found in + # the text โ€” see the design doc's "Mention injection is a real risk". A + # caller that already set one (Task 6's route alerts, with real configured + # targets) is left untouched; this only fills the gap for callers that + # never think about mentions at all (the static test message, the overflow + # string, voice-mention prefixes). + defp attach_allowed_mentions(message) do + if Map.has_key?(message, "content") and not Map.has_key?(message, "allowed_mentions") do + Map.put(message, "allowed_mentions", Mentions.allowed_mentions([])) + else + message + end + end +``` + +- [ ] **Step 4: Run test to verify it passes, then run the wider Discord suite** + +Run: `mix test test/unit/external_events/discord/worker_test.exs` +Expected: PASS, all cases including the pre-existing ones (payload shape is +unchanged for embed-only messages). + +Run: `mix test test/unit/external_events/discord_dispatcher_test.exs test/unit/external_events/discord/` +Expected: PASS โ€” this payload change is defensive-only (adds a key, never +removes or renames one), so the existing kill and voice-mention assertions +on `body["content"]` must be unaffected. If any assertion there breaks, it +is asserting on the literal message map rather than the delivered body and +needs updating to account for the new key โ€” do not weaken +`attach_allowed_mentions/1` to make a wrong assertion pass. + +- [ ] **Step 5: Commit** + +```bash +git add lib/wanderer_app/external_events/discord/worker.ex \ + test/unit/external_events/discord/worker_test.exs +git commit -m "fix(discord): attach allowed_mentions to every content-carrying message" +``` + +--- + +#### 4.4 โ€” Fold Task 3's duplicated regex into `Mentions` + +Task 3 shipped `MapDiscordWebhook.ValidateMentionTargets` with its own copy of +`~r/^(user|role):\d{17,20}$/` so that it could land before `Mentions` existed. +`Mentions` now exists, so the copy goes away and `Mentions.valid_target?/1` +becomes the single definition. This step is a **human-partner ruling made +before execution**, not an optional cleanup โ€” do not skip it. + +The direction of the dependency matters: the Ash resource depends on +`Mentions`, never the reverse. `Mentions` must keep its own regex literal and +gain no reference to the resource. + +- [ ] **Step 1: Write the failing test** + +Add to `test/unit/api/map_discord_webhook_test.exs`: + +```elixir + test "mention_targets validation rejects a malformed target via Mentions.valid_target?/1" do + # Same rejection as before the fold โ€” this asserts the behaviour survives + # the delegation, and the assertion below pins that there is now exactly + # one regex literal for mention targets in lib/. + assert WandererApp.ExternalEvents.Discord.Mentions.valid_target?("role:123456789012345678") + refute WandererApp.ExternalEvents.Discord.Mentions.valid_target?("role:123") + + resource_source = + File.read!("lib/wanderer_app/api/map_discord_webhook.ex") + + refute resource_source =~ ~S{~r/^(user|role):}, + "ValidateMentionTargets must delegate to Mentions.valid_target?/1, not carry its own regex" + end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/api/map_discord_webhook_test.exs -k "malformed target via Mentions"` + +If `-k` is not supported by this project's ExUnit invocation, run the file and +read the one failure. +Expected: FAIL on the `refute resource_source =~` assertion โ€” Task 3's regex +literal is still in the resource. The two `valid_target?/1` assertions pass +already; they are there to prove the delegation target behaves identically. + +- [ ] **Step 3: Delegate the validation** + +In `lib/wanderer_app/api/map_discord_webhook.ex`, replace +`ValidateMentionTargets`'s `@target_regex` and `valid?/1` with a call into +`Mentions`. The `validate/3` body is unchanged. + +```elixir + defmodule ValidateMentionTargets do + @moduledoc false + use Ash.Resource.Validation + + alias WandererApp.ExternalEvents.Discord.Mentions + + @impl true + def validate(changeset, _opts, _context) do + case Ash.Changeset.get_argument_or_attribute(changeset, :mention_targets) do + nil -> + :ok + + targets when is_list(targets) -> + if Enum.all?(targets, &Mentions.valid_target?/1) do + :ok + else + {:error, + field: :mention_targets, + message: "each entry must match user: or role: (17-20 digit snowflake)"} + end + + _ -> + :ok + end + end + end +``` + +Then update the comment above `@target_regex` in +`lib/wanderer_app/external_events/discord/mentions.ex` โ€” it currently says +"matches `MapDiscordWebhook.ValidateMentionTargets`. Kept as a separate literal +hereโ€ฆ", which is now backwards: + +```elixir + # Guild snowflakes are 17-20 decimal digits. This is the single definition of + # a well-formed mention target; `MapDiscordWebhook.ValidateMentionTargets` + # delegates here. The dependency runs resource -> Mentions and must not be + # reversed: this module stays free of any Ash compile-time dependency. + @target_regex ~r/^(user|role):(\d{17,20})$/ +``` + +- [ ] **Step 4: Run the tests** + +Run: `mix test test/unit/api/map_discord_webhook_test.exs test/unit/external_events/discord/mentions_test.exs` +Expected: PASS, including every `mention_targets` rejection case Task 3 wrote. +Those tests are the real proof of the fold โ€” they were written against the +deleted regex and must pass unchanged against the delegated one. + +- [ ] **Step 5: Format and commit** + +```bash +mix format lib/wanderer_app/api/map_discord_webhook.ex \ + lib/wanderer_app/external_events/discord/mentions.ex \ + test/unit/api/map_discord_webhook_test.exs +git add lib/wanderer_app/api/map_discord_webhook.ex \ + lib/wanderer_app/external_events/discord/mentions.ex \ + test/unit/api/map_discord_webhook_test.exs +git commit -m "refactor(discord): single source of truth for the mention-target pattern" +``` + +--- + +# Part 03 โ€” Router destination and the route-alert embed + +Depends on Task 3 (`:route` role, `mention_targets`) and Task 4 (`Mentions` +module) for compilation; both tasks below are otherwise self-contained per the +[shared contract](#shared-interface-contract). + +### Task 5: `Router.route_destination/1` + +**Files:** +- Modify: `lib/wanderer_app/external_events/discord/router.ex:1-94` +- Test: `test/unit/external_events/discord/router_test.exs` + +**Interfaces:** +- Consumes: `notification.webhooks` (must be loaded, same convention as `route/3`), the private `webhook/2` and `usable/1` helpers already defined at `router.ex:85-93`. +- Produces: `@spec route_destination(struct()) :: {:ok, struct()} | :drop` (contract Task 5). + +- [ ] **Step 1: Write the failing tests** + +Append to `test/unit/external_events/discord/router_test.exs`, inside a new +`describe "route_destination/1"` block (add `alias WandererApp.SystemClass` is +already present; no new aliases needed): + +```elixir + describe "route_destination/1" do + defp add_route_webhook(notification) do + {:ok, wh} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: "https://discord.com/api/webhooks/3/route" + }) + + wh + end + + test "a :route webhook is selected when present and enabled", %{notification: n} do + route_wh = add_route_webhook(n) + + assert {:ok, %{id: id}} = Router.route_destination(with_webhooks(n)) + assert id == route_wh.id + end + + # Compatibility guarantee, mirroring rule 3's fallback: every map with only + # a :system webhook keeps working with no user action once route alerts + # ship. + test "falls back to the system webhook when no :route row exists", %{ + notification: n, + system_wh: system_wh + } do + assert {:ok, %{id: id}} = Router.route_destination(with_webhooks(n)) + assert id == system_wh.id + end + + # DROP, NOT REROUTE โ€” the same rule `RouterTest` asserts for the character + # webhook in "a disabled character webhook drops rather than rerouting". + # A route alert *is* the chain topology (see the Router moduledoc); posting + # it to a channel the user did not choose for this purpose is a privacy + # violation, not a convenience. + test "a disabled :route webhook drops rather than falling back to :system", %{ + notification: n + } do + route_wh = add_route_webhook(n) + {:ok, _} = MapDiscordWebhook.set_enabled(route_wh, %{enabled?: false}) + + assert Router.route_destination(with_webhooks(n)) == :drop + end + + test "drops when neither :route nor :system exists" do + # `MapDiscordNotification.create/1` always seeds a :system webhook + # (see the module setup), so simulate "neither configured" the same way + # the unloaded-relationship test does: a notification struct whose + # webhooks list is empty rather than absent. + empty = %{webhooks: []} + + assert Router.route_destination(empty) == :drop + end + + test "an unloaded :webhooks relationship drops instead of raising", %{notification: n} do + {:ok, unloaded} = MapDiscordNotification.by_id(n.id) + assert %Ash.NotLoaded{} = unloaded.webhooks + + assert Router.route_destination(unloaded) == :drop + end + end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mix test test/unit/external_events/discord/router_test.exs` +Expected: FAIL with `UndefinedFunctionError` โ€” `Router.route_destination/1` is +undefined. + +- [ ] **Step 3: Extend the Router moduledoc** + +Insert a new section into the moduledoc at `router.ex`, directly after the +existing "## Disabled destinations drop; they do not reroute" section (so it +reads as a sibling rule, not a footnote): + +```elixir + ## Route alerts have their own destination + + `route_destination/1` resolves a route alert to the `:route` webhook, + falling back to the `:system` webhook when no `:route` row exists โ€” the same + fallback pattern rule 3 uses for `:character`. Existing maps therefore need + no configuration change to keep receiving route alerts once the feature is + enabled. + + A route alert *is* the chain topology: the path field names every system a + scout has found, in order, from the map's home system to Jita. There is no + redacted version of that message. A configured-but-disabled `:route` webhook + therefore drops rather than falling back to `:system` โ€” the same + disabled-drops-never-reroutes rule above, for the same reason: silence must + mean silence, not misdirection into a channel the user did not pick for a + message this sensitive. +``` + +- [ ] **Step 4: Implement `route_destination/1`** + +```elixir + @doc """ + Resolves a route alert to a destination. `notification` must have + `:webhooks` loaded. + """ + @spec route_destination(struct()) :: {:ok, struct()} | :drop + def route_destination(notification) do + usable(webhook(notification, :route) || webhook(notification, :system)) + end +``` + +Place it directly after `route/3` and before the `webhook/2` private helpers, +so both public functions sit above the helpers they share. No changes to +`webhook/2` or `usable/1` โ€” they are reused as-is. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `mix test test/unit/external_events/discord/router_test.exs` +Expected: PASS, all tests including the pre-existing rule 1-4 tests. + +- [ ] **Step 6: Format and lint** + +Run: `mix format lib/wanderer_app/external_events/discord/router.ex test/unit/external_events/discord/router_test.exs` +Run: `mix credo lib/wanderer_app/external_events/discord/router.ex` +Expected: clean. + +- [ ] **Step 7: Commit** +```bash +git add lib/wanderer_app/external_events/discord/router.ex test/unit/external_events/discord/router_test.exs +git commit -m "feat(discord): add route_destination/1 to Router + +Resolves route alerts to the :route webhook, falling back to :system +when no :route row exists. A disabled :route webhook drops rather than +falling back, matching the existing disabled-drops-never-reroutes rule." +``` + +--- + +### Task 6: `EmbedFormatter.format_route_alert/2` + +**Files:** +- Modify: `lib/wanderer_app/external_events/discord/embed_formatter.ex` +- Modify: `lib/wanderer_app/external_events/discord/system_name.ex` +- Test: `test/unit/external_events/discord/embed_formatter_test.exs` +- Test: `test/unit/external_events/discord/system_name_test.exs` + +**Interfaces:** +- Consumes: + - `alert :: %{kind: :opened | :improved, jumps: pos_integer(), path: [integer()], exit_system: integer() | nil, map_id: binary(), home_system_id: integer()}` (contract Task 2 / Task 6). + - `opts :: [mention_targets: [String.t()]]`. + - `WandererApp.ExternalEvents.Discord.SystemName.display_name/3`, extended with a literal `:route` clause (this task). + - `WandererApp.ExternalEvents.Discord.Mentions.prefix/1` and `Mentions.allowed_mentions/1` (Task 4 โ€” consumed, not reimplemented). + - `WandererApp.Env.discord_mentions_enabled?/0` (Task 4). +- Produces: `@spec format_route_alert(alert :: map(), opts :: keyword()) :: [map()]` (contract Task 6). Each returned chunk is a map with `"embeds"`, and `"content"` / `"allowed_mentions"` together when pinging. + +- [ ] **Step 1: Add the `:route` clause to `SystemName`, with its own failing test first** + +Append to `test/unit/external_events/discord/system_name_test.exs`, in a new +`describe` block: + +```elixir + describe "display_name/3 for :route" do + # Route alerts render the map's own chain, so they carry the same privacy + # boundary as the :system webhook โ€” map-local names are the point, not a + # leak. This is why the Router passes the atom :route literally rather + # than threading a variable: see the Router moduledoc's "Role resolution + # is literal" note. + test "resolves map-local names, same as :system", %{map: map} do + Factory.insert(:map_system, %{ + map_id: map.id, + solar_system_id: @wh_system, + name: "J115405", + temporary_name: "HOME" + }) + + assert SystemName.display_name(map.id, @wh_system, :route) == "HOME" + end + + test "falls through to the canonical name when no map-local name is set", %{map: map} do + assert SystemName.display_name(map.id, @ks_system, :route) == "Jita" + end + end +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mix test test/unit/external_events/discord/system_name_test.exs` +Expected: FAIL โ€” `FunctionClauseError` on `display_name/3`, no clause matching `:route`. + +- [ ] **Step 3: Implement the `:route` clause** + +```elixir + @type role :: :system | :character | :route + + @doc """ + The system name to render for `role`. + + Returns `nil` when no name can be resolved at all; the formatter renders + "Unknown system" in that case rather than guessing. + """ + @spec display_name(String.t(), integer(), role()) :: String.t() | nil + def display_name(_map_id, solar_system_id, :character), do: canonical_name(solar_system_id) + + def display_name(map_id, solar_system_id, :system) do + map_local_name(map_id, solar_system_id) || canonical_name(solar_system_id) + end + + # Route alerts carry the same map-local-names privacy boundary as :system: + # the whole message is the map's own chain, so the resolution order matches + # :system exactly rather than falling through to :character's canonical-only + # behavior. + def display_name(map_id, solar_system_id, :route) do + map_local_name(map_id, solar_system_id) || canonical_name(solar_system_id) + end +``` + +Update the moduledoc's `@type role` line implicitly via the typespec above; +no prose change needed since the existing moduledoc already frames this as a +"per destination role" resolver. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `mix test test/unit/external_events/discord/system_name_test.exs` +Expected: PASS. + +- [ ] **Step 5: Write the failing formatter tests โ€” shape, title, and the improved variant** + +Append to `test/unit/external_events/discord/embed_formatter_test.exs`. This +describe block needs real system names, so it seeds +`:system_static_info_cache` and switches the file's test to `WandererApp.DataCase` +semantics for this block only via a nested module, OR (simpler, matching how +`SystemNameTest` already does it) the whole file's `use` clause changes. Since +`format_route_alert/2` calls `SystemName.display_name/3`, which hits Cachex and +the DB, `EmbedFormatterTest` must change its `use` from `ExUnit.Case` to +`WandererApp.DataCase, async: false` โ€” every existing test in this file is a +pure function over a killmail map and is unaffected by that switch. + +```elixir +defmodule WandererApp.ExternalEvents.Discord.EmbedFormatterTest do + use WandererApp.DataCase, async: false + + alias WandererApp.ExternalEvents.Discord.EmbedFormatter + alias WandererAppWeb.Factory +``` + +(Remove the old `use ExUnit.Case, async: true` line.) + +Then add: + +```elixir + describe "format_route_alert/2" do + @home 31_000_005 + @wh_hop 31_000_006 + @exit_system 30_002_053 + @jita 30_000_142 + + setup do + Cachex.put(:system_static_info_cache, @home, %{ + solar_system_id: @home, + solar_system_name: "J115405", + system_class: 3 + }) + + Cachex.put(:system_static_info_cache, @wh_hop, %{ + solar_system_id: @wh_hop, + solar_system_name: "J132412", + system_class: 3 + }) + + Cachex.put(:system_static_info_cache, @exit_system, %{ + solar_system_id: @exit_system, + solar_system_name: "Amarr", + system_class: 0 + }) + + Cachex.put(:system_static_info_cache, @jita, %{ + solar_system_id: @jita, + solar_system_name: "Jita", + system_class: 0 + }) + + on_exit(fn -> + Enum.each( + [@home, @wh_hop, @exit_system, @jita], + &Cachex.del(:system_static_info_cache, &1) + ) + end) + + map = Factory.insert(:map, %{}) + + alert = %{ + kind: :opened, + jumps: 4, + path: [@home, @wh_hop, @exit_system, @jita], + exit_system: @exit_system, + map_id: map.id, + home_system_id: @home + } + + %{alert: alert} + end + + test "an opened alert is a single green embed titled with the jump count", %{alert: alert} do + assert [%{"embeds" => [embed]}] = EmbedFormatter.format_route_alert(alert, []) + + assert embed["title"] == "Highsec route to Jita โ€” 4 jumps" + assert embed["color"] == 0x2ECC71 + end + + test "the path renders home through Jita using map-local names for wormhole hops", %{ + alert: alert + } do + [%{"embeds" => [embed]}] = EmbedFormatter.format_route_alert(alert, []) + + path_field = Enum.find(embed["fields"], &(&1["name"] == "Path")) + assert path_field["value"] == "J115405 โ†’ J132412 โ†’ Amarr โ†’ Jita" + end + + test "the exit system gets its own field", %{alert: alert} do + [%{"embeds" => [embed]}] = EmbedFormatter.format_route_alert(alert, []) + + exit_field = Enum.find(embed["fields"], &(&1["name"] == "Exit system")) + assert exit_field["value"] == "Amarr" + end + + test "an improved alert titles with 'improved' and carries no content", %{alert: alert} do + improved = %{alert | kind: :improved, jumps: 3} + + assert [%{"embeds" => [embed]} = message] = EmbedFormatter.format_route_alert(improved, []) + assert embed["title"] == "Highsec route to Jita improved โ€” 3 jumps" + refute Map.has_key?(message, "content") + end + end +``` + +- [ ] **Step 6: Run tests to verify they fail** + +Run: `mix test test/unit/external_events/discord/embed_formatter_test.exs` +Expected: FAIL with `UndefinedFunctionError` for `format_route_alert/2`. + +- [ ] **Step 7: Implement `format_route_alert/2` (no mentions yet)** + +Add to `embed_formatter.ex`, near `format_batch/2`: + +```elixir + alias WandererApp.ExternalEvents.Discord.SystemName + + @color_route 0x2ECC71 + + @doc """ + Formats a route-alert transition (design ยง"Message, mentions, and privacy") + into Discord message chunks. `opts[:mention_targets]` are guild-scoped + snowflake strings (`"user:123"` / `"role:456"`); pinging is gated on + `WandererApp.Env.discord_mentions_enabled?/0` and fires only for `:opened` + (design: "Ping on open only" โ€” an "improved" update posts with no `content`, + keeping the ping meaningful on a chain under active scanning). + """ + @spec format_route_alert(map(), keyword()) :: [map()] + def format_route_alert(alert, opts) do + embed = route_embed(alert) + mention_targets = Keyword.get(opts, :mention_targets, []) + + message = + case route_ping(alert.kind, mention_targets) do + nil -> + %{"embeds" => [embed]} + + {content, allowed_mentions} -> + %{"embeds" => [embed], "content" => content, "allowed_mentions" => allowed_mentions} + end + + [message] + end + + defp route_embed(alert) do + %{ + "title" => route_title(alert), + "color" => @color_route, + "fields" => [ + %{"name" => "Path", "value" => route_path_text(alert), "inline" => false}, + %{ + "name" => "Exit system", + "value" => route_system_name(alert, alert.exit_system), + "inline" => true + } + ] + } + |> drop_nils() + end + + defp route_title(%{kind: :opened, jumps: jumps}), + do: "Highsec route to Jita โ€” #{jumps} jumps" + + defp route_title(%{kind: :improved, jumps: jumps}), + do: "Highsec route to Jita improved โ€” #{jumps} jumps" + + defp route_path_text(alert) do + alert.path + |> Enum.map(&route_system_name(alert, &1)) + |> Enum.join(" โ†’ ") + end + + # Literal :route, per SystemName's map-local-names privacy boundary โ€” never + # threaded through as a variable. See the Router moduledoc's "Role + # resolution is literal" note and SystemName's own moduledoc. + defp route_system_name(_alert, nil), do: "Unknown system" + + defp route_system_name(alert, solar_system_id) do + SystemName.display_name(alert.map_id, solar_system_id, :route) || "Unknown system" + end +``` + +Note: the destination is hardcoded to Jita (design decision 6), so the title +says "Jita" literally and no system-id constant is needed here. + +- [ ] **Step 8: Run tests to verify the shape/title/path/exit-system tests pass** + +Run: `mix test test/unit/external_events/discord/embed_formatter_test.exs --only describe:"format_route_alert/2"` + +(If `--only describe:` tag filtering is unavailable in this test's ExUnit +config, run the whole file instead: +`mix test test/unit/external_events/discord/embed_formatter_test.exs`.) + +Expected: shape/title/path/exit-system/improved-no-content tests PASS. +Mention-related tests below still fail (not yet written) โ€” this step only +confirms the embed itself. + +- [ ] **Step 9: Write the failing mention tests** + +Append to the same `describe "format_route_alert/2"` block: + +```elixir + test "an opened alert with configured targets carries a content ping and allowed_mentions", %{ + alert: alert + } do + put_discord_mentions_enabled(true) + on_exit(fn -> Application.delete_env(:wanderer_app, :discord_mentions_enabled) end) + + [message] = + EmbedFormatter.format_route_alert(alert, mention_targets: ["role:123456789012345678"]) + + assert message["content"] =~ "<@&123456789012345678>" + assert message["allowed_mentions"] == + %{"parse" => [], "users" => [], "roles" => ["123456789012345678"]} + end + + test "no content when no mention targets are configured", %{alert: alert} do + put_discord_mentions_enabled(true) + on_exit(fn -> Application.delete_env(:wanderer_app, :discord_mentions_enabled) end) + + [message] = EmbedFormatter.format_route_alert(alert, mention_targets: []) + + refute Map.has_key?(message, "content") + refute Map.has_key?(message, "allowed_mentions") + end + + test "no content when the mentions env gate is off, even with targets configured", %{ + alert: alert + } do + put_discord_mentions_enabled(false) + on_exit(fn -> Application.delete_env(:wanderer_app, :discord_mentions_enabled) end) + + [message] = + EmbedFormatter.format_route_alert(alert, mention_targets: ["role:123456789012345678"]) + + refute Map.has_key?(message, "content") + end + + test "an improved alert never carries content, even with targets configured", %{alert: alert} do + put_discord_mentions_enabled(true) + on_exit(fn -> Application.delete_env(:wanderer_app, :discord_mentions_enabled) end) + + improved = %{alert | kind: :improved} + + [message] = + EmbedFormatter.format_route_alert(improved, mention_targets: ["role:123456789012345678"]) + + refute Map.has_key?(message, "content") + refute Map.has_key?(message, "allowed_mentions") + end + + # Mention injection guard (design: "Mention injection is a real risk"). + # A system's temporary_name is user-supplied and goes in the EMBED only; + # it must never reach `content`, and `allowed_mentions` must stay a closed + # allowlist regardless of what the embed renders. + test "a system named @everyone does not inject into content", %{alert: alert} do + put_discord_mentions_enabled(true) + on_exit(fn -> Application.delete_env(:wanderer_app, :discord_mentions_enabled) end) + + Factory.insert(:map_system, %{ + map_id: alert.map_id, + solar_system_id: @home, + name: "J115405", + temporary_name: "@everyone" + }) + + [message] = + EmbedFormatter.format_route_alert(alert, mention_targets: ["role:123456789012345678"]) + + refute message["content"] =~ "@everyone" + assert message["allowed_mentions"] == + %{"parse" => [], "users" => [], "roles" => ["123456789012345678"]} + + [%{"embeds" => [embed]}] = [message] + + assert embed["fields"] |> Enum.find(&(&1["name"] == "Path")) |> Map.get("value") =~ + "@everyone" + end + + # `Env.discord_mentions_enabled?/0` reads a `:discord_mentions_enabled` key + # NESTED inside the `:external_events` keyword list (`env.ex:274-276`), not a + # top-level app env key. Setting the top-level key would leave the gate at its + # `true` default and the gate-off test would fail. Mirrors the shape used by + # `test/unit/env_discord_mentions_test.exs`. + defp put_discord_mentions_enabled(enabled?) do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :discord_mentions_enabled, enabled?) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + end +``` + +- [ ] **Step 10: Run tests to verify they fail** + +Run: `mix test test/unit/external_events/discord/embed_formatter_test.exs` +Expected: FAIL โ€” `route_ping/2` undefined (compile error) or mention +assertions failing, since `format_route_alert/2` currently ignores +`mention_targets` and the mentions gate entirely. + +- [ ] **Step 11: Implement mention gating, consuming `Mentions` (Task 4) โ€” do not reimplement prefix/allowlist logic** + +```elixir + alias WandererApp.ExternalEvents.Discord.Mentions + + defp route_ping(:improved, _mention_targets), do: nil + defp route_ping(:opened, []), do: nil + + defp route_ping(:opened, mention_targets) do + if WandererApp.Env.discord_mentions_enabled?() do + case Mentions.prefix(mention_targets) do + nil -> nil + content -> {content, Mentions.allowed_mentions(mention_targets)} + end + end + end +``` + +- [ ] **Step 12: Run all tests to verify they pass** + +Run: `mix test test/unit/external_events/discord/embed_formatter_test.exs test/unit/external_events/discord/system_name_test.exs` +Expected: PASS, full file including every pre-existing kill-embed test (the +`use` clause change to `DataCase, async: false` must not break them โ€” they are +pure functions and take no dependency on the case template beyond running +inside it). + +- [ ] **Step 13: Format and lint** + +Run: `mix format lib/wanderer_app/external_events/discord/embed_formatter.ex lib/wanderer_app/external_events/discord/system_name.ex test/unit/external_events/discord/embed_formatter_test.exs test/unit/external_events/discord/system_name_test.exs` +Run: `mix credo lib/wanderer_app/external_events/discord/embed_formatter.ex lib/wanderer_app/external_events/discord/system_name.ex` +Expected: clean. + +- [ ] **Step 14: Commit** +```bash +git add lib/wanderer_app/external_events/discord/embed_formatter.ex \ + lib/wanderer_app/external_events/discord/system_name.ex \ + test/unit/external_events/discord/embed_formatter_test.exs \ + test/unit/external_events/discord/system_name_test.exs +git commit -m "feat(discord): add format_route_alert/2 to EmbedFormatter + +Green embed with the full home-to-Jita path (map-local names via a +literal :route SystemName clause) and its own exit-system field. Pings +on :opened only, gated on the mentions env flag, and always pairs +content with a closed allowed_mentions allowlist so a user-supplied +system name can never inject a live mention." +``` + +--- + +### Task 7: `Discord.RouteWatcher` + +**Files:** +- Create: `lib/wanderer_app/external_events/discord/route_watcher.ex` +- Test: `test/unit/external_events/discord/route_watcher_test.exs` + +**Interfaces:** +- Consumes: `WandererApp.Api.MapDiscordNotification.by_map/1` (+ `Ash.load(:webhooks)`), + `WandererApp.Map.RouteAlert.Evaluator.evaluate/2`, `.solver_settings/0`, + `.jita_system_id/0` (Task 2); `WandererApp.Map.Routes.find_strict/5` (Task 1, + behind a swappable impl โ€” see Step 2); `WandererApp.ExternalEvents.Discord.Router.route_destination/1` + (Task 5); `WandererApp.ExternalEvents.Discord.EmbedFormatter.format_route_alert/2` + (Task 6); `WandererApp.ExternalEvents.Discord.WorkerSupervisor.deliver/2` + (existing); `Task.Supervisor` named `WandererApp.ExternalEvents.Discord.TaskSupervisor` + (existing, `worker_supervisor.ex:34`). +- Produces: `notify(binary()) :: :ok`, `config_version(struct()) :: binary()`, + `registry/0` (consumed by Task 8), telemetry + `[:wanderer_app, :discord, :route_alert]` with an `:outcome` tag. + +One GenServer per map, Registry-addressed exactly like `Discord.Worker` +(`worker.ex:84-88`), except keyed by `map_id` instead of `webhook_id`, and it +owns its own Registry name rather than receiving one โ€” `RouteWatcherSupervisor` +(Task 8) asks `RouteWatcher.registry/0` for it, so there is exactly one place +the atom is defined. + +State shape: + +```elixir +%{ + map_id: binary(), + route_state: :unknown | :none | {:qualifying, pos_integer()}, + config_version: binary() | nil, + timer_ref: reference() | nil, + first_notify_at: integer() | nil, + task: Task.t() | nil, + task_deadline_ref: reference() | nil, + rerun?: boolean(), + pending_notification: struct() | nil, + debounce_ms: pos_integer(), + ceiling_ms: pos_integer(), + task_timeout_ms: pos_integer() +} +``` + +`debounce_ms` / `ceiling_ms` / `task_timeout_ms` are overridable `start_link` +opts, mirroring `Worker`'s `idle_timeout` override (`worker.ex:105`), so tests +exercise the 10s/60s/20s behaviour in milliseconds instead of real seconds. + +- [ ] **Step 1: Failing test โ€” first notify arms the debounce and evaluates once** + +```elixir +defmodule WandererApp.ExternalEvents.Discord.RouteWatcherTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.ExternalEvents.Discord.{RouteWatcher, WorkerSupervisor} + alias WandererAppWeb.Factory + + @jita 30_000_142 + + setup do + start_supervised!(WorkerSupervisor) + Application.put_env(:wanderer_app, :route_alert_solver, WandererApp.ExternalEvents.Discord.RouteWatcherTest.StubSolver) + Process.put(:route_alert_stub_result, {:ok, %{routes: [], systems_static_data: []}}) + + on_exit(fn -> Application.delete_env(:wanderer_app, :route_alert_solver) end) + + map = Factory.insert(:map, %{}) + + {:ok, notification} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: "https://discord.com/api/webhooks/1/tok"}) + + {:ok, notification} = + MapDiscordNotification.update(notification, %{ + route_alerts_enabled?: true, + home_system_id: 30_000_001, + route_max_jumps: 5 + }) + + %{map: map, notification: notification} + end + + defp start_watcher(map_id, opts \\ []) do + default = [map_id: map_id, debounce_ms: 30, ceiling_ms: 200, task_timeout_ms: 500] + start_supervised!({RouteWatcher, Keyword.merge(default, opts)}, id: {RouteWatcher, map_id}) + end + + defp sync(map_id) do + case Registry.lookup(RouteWatcher.registry(), map_id) do + [{pid, _}] -> :sys.get_state(pid) + [] -> flunk("no watcher registered for map #{map_id}") + end + end + + test "a single notify debounces then evaluates once", %{map: map} do + {:ok, pid} = start_watcher(map.id) + RouteWatcher.notify(map.id) + + # Immediately after notify the debounce timer is armed but no task has run. + assert %{task: nil, timer_ref: ref} = :sys.get_state(pid) + assert is_reference(ref) + + Process.sleep(60) + assert %{route_state: :unknown, timer_ref: nil} = :sys.get_state(pid) + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** +Run: `mix test test/unit/external_events/discord/route_watcher_test.exs` +Expected: FAIL โ€” `module WandererApp.ExternalEvents.Discord.RouteWatcher is not available` +(the stub solver module referenced in `setup` does not exist yet either; add it +as a private module at the bottom of the test file once the real module compiles +โ€” see Step 3). + +- [ ] **Step 3: Minimal GenServer โ€” registry, init, debounce timer, no solve yet** + +```elixir +defmodule WandererApp.ExternalEvents.Discord.RouteWatcher do + @moduledoc """ + One GenServer per map: owns the debounce timer, the last-known route state, + its `config_version`, and the in-flight solver task. Registry-addressed like + `Discord.Worker` (`worker.ex`), keyed by `map_id` instead of `webhook_id`. + + ## Why the solver task never blocks this process + + `Task.yield(20_000) || Task.shutdown(:brutal_kill)` โ€” the idiom + `DiscordDispatcher`'s enrichment steps use โ€” is wrong here. It parks this + process for up to 20s, during which it cannot receive the `notify` casts that + are supposed to set the re-run flag. Those casts would sit in the mailbox and + be processed *after* the stale result was already published, so a connection + closing mid-solve could still produce a false "opened" alert. The dispatcher + can afford to block because it is enriching a payload it already holds; this + process cannot, because incoming events invalidate the work in flight. + + So the task runs via `Task.Supervisor.async_nolink/2`, its ref (inside the + `%Task{}` struct, not bare) is stored in state, and both `{ref, result}` and + `{:DOWN, ref, ...}` are handled in `handle_info`. A `Process.send_after/3` + deadline enforces the 20s budget from the timeout handler, calling + `Task.shutdown(task, :brutal_kill)` โ€” which itself drains the matching `:DOWN` + or `{ref, result}` message, so no separate cleanup clause is needed for a + self-inflicted shutdown. + + A notify arriving while a task is in flight only sets `rerun?: true`; the + result handler discards the in-flight answer and starts a fresh evaluation + immediately when it lands with `rerun?` set, rather than publishing a result + that may already be stale. + """ + + use GenServer, restart: :transient + + require Logger + + @registry WandererApp.ExternalEvents.Discord.RouteWatcherRegistry + @cache :discord_route_alert_cache + + @debounce_ms 10_000 + @ceiling_ms 60_000 + @task_timeout_ms 20_000 + + def start_link(opts) do + map_id = Keyword.fetch!(opts, :map_id) + GenServer.start_link(__MODULE__, opts, name: via(map_id)) + end + + @doc "Queues a re-evaluation for this map. The watcher must already be running." + @spec notify(binary()) :: :ok + def notify(map_id) do + GenServer.cast(via(map_id), :notify) + end + + @doc "The Registry this module is addressed through. Owned here, read by RouteWatcherSupervisor." + def registry, do: @registry + + defp via(map_id), do: {:via, Registry, {@registry, map_id}} + + @impl true + def init(opts) do + map_id = Keyword.fetch!(opts, :map_id) + + state = %{ + map_id: map_id, + route_state: :unknown, + config_version: nil, + timer_ref: nil, + first_notify_at: nil, + task: nil, + task_deadline_ref: nil, + rerun?: false, + pending_notification: nil, + debounce_ms: Keyword.get(opts, :debounce_ms, @debounce_ms), + ceiling_ms: Keyword.get(opts, :ceiling_ms, @ceiling_ms), + task_timeout_ms: Keyword.get(opts, :task_timeout_ms, @task_timeout_ms) + } + + {:ok, rehydrate(state)} + end + + # Only the raw {route_state, config_version} pair is rehydrated here. The + # config_version comparison against the map's CURRENT configuration happens + # in start_evaluation/1 on the next notify, exactly as it does on every other + # evaluation โ€” deferring it avoids a DB read on every process start for + # watchers that are started but never fire (e.g. a crash-restart loop). + defp rehydrate(state) do + case Cachex.get(@cache, state.map_id) do + {:ok, %{route_state: rs, config_version: cv}} -> + %{state | route_state: rs, config_version: cv} + + _ -> + state + end + rescue + # Cache not started in every test context; a fresh :unknown state is the + # correct fallback, not a crash. + _ -> state + end + + @impl true + def handle_cast(:notify, %{task: task} = state) when not is_nil(task) do + {:noreply, %{state | rerun?: true}} + end + + def handle_cast(:notify, state) do + {:noreply, arm_timer(state)} + end + + defp arm_timer(state) do + now = System.monotonic_time(:millisecond) + first_notify_at = state.first_notify_at || now + + if state.timer_ref, do: Process.cancel_timer(state.timer_ref) + + # Re-armed to the full debounce on every notify, but never pushed past the + # ceiling measured from the FIRST notify of this burst โ€” otherwise a chain + # under continuous scanning (a notify at least once every debounce_ms) + # would never evaluate at all. + remaining_to_ceiling = first_notify_at + state.ceiling_ms - now + delay = min(state.debounce_ms, max(remaining_to_ceiling, 0)) + + timer_ref = Process.send_after(self(), :evaluate, delay) + %{state | timer_ref: timer_ref, first_notify_at: first_notify_at} + end + + @impl true + def handle_info(:evaluate, state) do + {:noreply, %{state | timer_ref: nil, first_notify_at: nil}} + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** +Run: `mix test test/unit/external_events/discord/route_watcher_test.exs` +Expected: PASS (the `:evaluate` handler above only clears the timer; the +`route_state` stays `:unknown` by construction, matching the assertion). + +- [ ] **Step 5: Commit** +```bash +git add lib/wanderer_app/external_events/discord/route_watcher.ex test/unit/external_events/discord/route_watcher_test.exs +git commit -m "feat(discord): scaffold RouteWatcher debounce timer" +``` + +- [ ] **Step 6: Failing test โ€” the solve runs off-process and the four transitions** + +Add a private stub solver at the bottom of the test file (referenced from Step +1's `setup`) and the four-transition test: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.RouteWatcherTest.StubSolver do + @moduledoc """ + Stands in for `WandererApp.Map.Routes.find_strict/5`. Reads its canned answer + from the process dictionary of whichever process calls it โ€” that process is + the Task the watcher spawns, not the test process, so the value is seeded via + `Application.put_env/3` instead (read once per call, mutated between phases + of a single test). + """ + def find_strict(_map_id, _hubs, _origin, _settings, _hubs_limit_reached?) do + Application.get_env(:wanderer_app, :route_alert_stub_result, {:ok, %{routes: [], systems_static_data: []}}) + end +end +``` + +```elixir + describe "transitions" do + setup %{map: map} do + {:ok, pid} = start_watcher(map.id) + %{pid: pid} + end + + # `Evaluator` is fail-closed: a system absent from `systems_static_data` + # disqualifies the whole route to `:none` (`evaluator.ex:63-64`, and + # `system_qualifies?/2`'s `:error -> false` clause). An empty list here + # would make every "qualifying" test below assert against `:none` and + # silently stop testing the transition logic โ€” so the origin AND every hop + # must be present and highsec. + defp qualifying_result(jumps, home) do + path = [home | Enum.to_list((home + 1)..(home + jumps))] + + {:ok, + %{ + routes: [ + %{ + has_connection: true, + systems: Enum.to_list((home + 1)..(home + jumps)), + origin: home, + destination: @jita, + success: true + } + ], + systems_static_data: + Enum.map(path, &%{solar_system_id: &1, security: "0.9", system_class: 7}) + }} + end + + test "unknown -> qualifying posts opened", %{map: map, pid: pid} do + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(4, 30_000_001)) + + RouteWatcher.notify(map.id) + Process.sleep(60) + + assert %{route_state: {:qualifying, 4}} = :sys.get_state(pid) + end + + test "qualifying(4) -> qualifying(2) posts improved", %{map: map, pid: pid} do + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(4, 30_000_001)) + RouteWatcher.notify(map.id) + Process.sleep(60) + + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(2, 30_000_001)) + RouteWatcher.notify(map.id) + Process.sleep(60) + + assert %{route_state: {:qualifying, 2}} = :sys.get_state(pid) + end + + test "qualifying(2) -> qualifying(4) is silent but still stores 4", %{map: map, pid: pid} do + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(2, 30_000_001)) + RouteWatcher.notify(map.id) + Process.sleep(60) + + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(4, 30_000_001)) + RouteWatcher.notify(map.id) + Process.sleep(60) + + assert %{route_state: {:qualifying, 4}} = :sys.get_state(pid) + end + + test "qualifying -> none clears silently", %{map: map, pid: pid} do + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(4, 30_000_001)) + RouteWatcher.notify(map.id) + Process.sleep(60) + + Application.put_env( + :wanderer_app, + :route_alert_stub_result, + {:ok, %{routes: [%{has_connection: false, systems: [], origin: 30_000_001, destination: @jita, success: false}], systems_static_data: []}} + ) + + RouteWatcher.notify(map.id) + Process.sleep(60) + + assert %{route_state: :none} = :sys.get_state(pid) + end + end +``` + +- [ ] **Step 7: Run test to verify it fails** +Run: `mix test test/unit/external_events/discord/route_watcher_test.exs` +Expected: FAIL โ€” `route_state` stays `:unknown` in every case; `:evaluate` does +not yet launch a task or call the Evaluator. + +- [ ] **Step 8: Implement the solve, the Evaluator hand-off, and the four-way transition** + +```elixir + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.ExternalEvents.Discord.{Router, WorkerSupervisor, EmbedFormatter} + alias WandererApp.Map.RouteAlert.Evaluator + + def handle_info(:evaluate, state) do + state = %{state | timer_ref: nil, first_notify_at: nil} + {:noreply, start_evaluation(state)} + end + + # -- launching a solve ------------------------------------------------------ + + defp start_evaluation(state) do + case load_notification(state.map_id) do + {:ok, notification} -> start_evaluation(state, notification) + :error -> state + end + end + + defp start_evaluation(state, notification) do + cv = config_version(notification) + + # A config change discards stored state to :unknown rather than comparing + # against a state that describes a different question ("State identity is + # versioned by config" in the design doc). + state = + if cv != state.config_version do + %{state | route_state: :unknown, config_version: cv} |> persist() + else + state + end + + if notification.route_alerts_enabled? and not is_nil(notification.home_system_id) do + launch_task(state, notification) + else + # Disabling clears outright. Re-enabling then starts from :none, which + # the transition table treats identically to :unknown โ€” the next + # qualifying result posts "opened" either way, so no special case. + %{state | route_state: :none, config_version: cv, pending_notification: nil} + |> persist() + end + end + + defp launch_task(state, notification) do + task = + Task.Supervisor.async_nolink( + WandererApp.ExternalEvents.Discord.TaskSupervisor, + fn -> solver_impl().find_strict( + notification.map_id, + [Integer.to_string(Evaluator.jita_system_id())], + Integer.to_string(notification.home_system_id), + Evaluator.solver_settings(), + false + ) end + ) + + deadline_ref = Process.send_after(self(), {:task_timeout, task.ref}, state.task_timeout_ms) + + %{ + state + | task: task, + task_deadline_ref: deadline_ref, + rerun?: false, + pending_notification: notification + } + end + + defp solver_impl, + do: Application.get_env(:wanderer_app, :route_alert_solver, WandererApp.Map.Routes) + + defp load_notification(map_id) do + with {:ok, notification} when not is_nil(notification) <- MapDiscordNotification.by_map(map_id), + {:ok, notification} <- Ash.load(notification, :webhooks) do + {:ok, notification} + else + _ -> :error + end + end + + # -- the result -------------------------------------------------------------- + + def handle_info({ref, result}, %{task: %Task{ref: ref}} = state) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + if state.task_deadline_ref, do: Process.cancel_timer(state.task_deadline_ref) + state = %{state | task: nil, task_deadline_ref: nil} + {:noreply, land_result(state, result)} + end + + # The task crashed outright (not our own :brutal_kill โ€” that path is handled + # entirely inside Task.shutdown/2 in the timeout handler below and never + # reaches here). Treated the same as a solver error: keep state, log, emit + # telemetry, do not alert. + def handle_info({:DOWN, ref, :process, _pid, reason}, %{task: %Task{ref: ref}} = state) + when is_reference(ref) do + if state.task_deadline_ref, do: Process.cancel_timer(state.task_deadline_ref) + state = %{state | task: nil, task_deadline_ref: nil} + {:noreply, land_result(state, {:error, reason})} + end + + # A late reply for a task we already shut down or whose deadline already + # fired for a *different* in-flight task (map restarted evaluation). + def handle_info({ref, _result}, state) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + {:noreply, state} + end + + def handle_info({:DOWN, ref, :process, _pid, _reason}, state) when is_reference(ref) do + {:noreply, state} + end + + defp land_result(state, result) do + if state.rerun? do + # A topology change arrived mid-solve: this answer no longer describes + # the current chain. Discard it and start a fresh solve immediately + # rather than waiting out another debounce window โ€” the coalescing + # already happened via the flag. + start_evaluation(%{state | rerun?: false}) + else + notification = state.pending_notification + outcome = Evaluator.evaluate(result, max_jumps: notification.route_max_jumps) + state = %{state | pending_notification: nil} + transition(state, notification, outcome) + end + end + + # -- the transition table ----------------------------------------------------- + + defp transition(state, _notification, :unknown) do + emit_telemetry(state, :unknown) + persist(state) + end + + defp transition(state, _notification, :none) do + emit_telemetry(state, :none) + persist(%{state | route_state: :none}) + end + + defp transition(%{route_state: prev} = state, notification, {:qualifying, %{jumps: jumps} = q}) do + case prev do + p when p in [:unknown, :none] -> alert(state, notification, :opened, q, jumps) + {:qualifying, old} when jumps < old -> alert(state, notification, :improved, q, jumps) + {:qualifying, _old} -> persist(%{state | route_state: {:qualifying, jumps}}) + end + end + + # State is written BEFORE delivery, matching DiscordDispatcher's + # at-most-once posture (`handle_delivery_result/4`): a delivery failure loses + # one alert rather than repeating it. `{:error, :not_running}` means nothing + # was enqueued, so the write is reverted exactly as the dispatcher does. + defp alert(state, notification, kind, qualifying, jumps) do + # `state` still carries the PREVIOUS route_state here โ€” captured as + # `prev_state` before the optimistic write, so a reverted delivery + # restores exactly what was there before this transition, not the new + # value we are about to persist. + prev_state = state + new_state = persist(%{state | route_state: {:qualifying, jumps}}) + + case Router.route_destination(notification) do + {:ok, webhook} -> + deliver_alert(new_state, prev_state, notification, webhook, kind, qualifying, jumps) + + :drop -> + new_state + end + end + + defp deliver_alert(state, prev_state, notification, webhook, kind, qualifying, jumps) do + alert = %{ + kind: kind, + jumps: jumps, + path: qualifying.path, + exit_system: qualifying.exit_system, + map_id: state.map_id, + home_system_id: notification.home_system_id + } + + messages = EmbedFormatter.format_route_alert(alert, mention_targets: webhook.mention_targets) + + case WorkerSupervisor.deliver(webhook.id, messages) do + :ok -> + emit_telemetry(state, kind) + state + + # Nothing was enqueued: revert the optimistic write to what it was + # before this transition, mirroring `handle_delivery_result/4`'s + # `{:error, :not_running}` clause in the dispatcher. + {:error, :not_running} -> + persist(%{state | route_state: prev_state.route_state}) + end + end + + defp emit_telemetry(state, outcome) do + :telemetry.execute( + [:wanderer_app, :discord, :route_alert], + %{count: 1}, + %{map_id: state.map_id, outcome: outcome} + ) + end + + defp persist(state) do + Cachex.put(@cache, state.map_id, %{route_state: state.route_state, config_version: state.config_version}) + state + rescue + _ -> state + end + + # -- config identity --------------------------------------------------------- + + @doc """ + Hashes the configuration that a stored route_state's meaning depends on. + A mismatch on rehydrate or on any evaluation means the stored value describes + a different question, and is discarded rather than compared + ("State identity is versioned by config" in the design doc). + """ + @spec config_version(struct()) :: binary() + def config_version(%{home_system_id: home_system_id, route_max_jumps: route_max_jumps}) do + {home_system_id, route_max_jumps, Evaluator.solver_settings()} + |> :erlang.term_to_binary() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + # -- the 20s solve deadline --------------------------------------------------- + + # Task.yield(20_000) || Task.shutdown(:brutal_kill) is deliberately NOT used + # here โ€” see the moduledoc. This handler is the alternative: a self-scheduled + # message fires the deadline instead of a blocking wait, so the mailbox (and + # therefore `notify/1`) stays live for the entire 20s. + def handle_info({:task_timeout, ref}, %{task: %Task{ref: ref}} = state) do + Task.shutdown(state.task, :brutal_kill) + + Logger.warning( + "[Discord.RouteWatcher] route solve exceeded #{state.task_timeout_ms}ms for map #{state.map_id}; killed" + ) + + emit_telemetry(state, :timeout) + state = %{state | task: nil, task_deadline_ref: nil} + + state = + if state.rerun? do + start_evaluation(%{state | rerun?: false}) + else + state + end + + {:noreply, state} + end + + # A deadline message for a task that already finished or was already killed โ€” + # its :task_timeout was cancelled, but cancellation is not guaranteed to beat + # a message already in the mailbox. Harmless no-op. + def handle_info({:task_timeout, _stale_ref}, state), do: {:noreply, state} +end +``` + +- [ ] **Step 9: Run test to verify it passes** +Run: `mix test test/unit/external_events/discord/route_watcher_test.exs` +Expected: PASS โ€” all four transition tests and the Step-1 debounce test green. + +- [ ] **Step 10: Commit** +```bash +git add lib/wanderer_app/external_events/discord/route_watcher.ex test/unit/external_events/discord/route_watcher_test.exs +git commit -m "feat(discord): RouteWatcher solves off-process and applies the transition table" +``` + +- [ ] **Step 11: Failing test โ€” solver error keeps state, and config_version mismatch resets to :unknown** + +```elixir + test "a solver error keeps prior state and does not alert", %{map: map, pid: pid} do + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(4, 30_000_001)) + RouteWatcher.notify(map.id) + Process.sleep(60) + assert %{route_state: {:qualifying, 4}} = :sys.get_state(pid) + + Application.put_env(:wanderer_app, :route_alert_stub_result, {:error, :solver_unreachable}) + RouteWatcher.notify(map.id) + Process.sleep(60) + + assert %{route_state: {:qualifying, 4}} = :sys.get_state(pid) + end + + test "a route_max_jumps change resets to :unknown and the next qualifying result opens", + %{map: map, notification: notification, pid: pid} do + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(4, 30_000_001)) + RouteWatcher.notify(map.id) + Process.sleep(60) + assert %{route_state: {:qualifying, 4}} = :sys.get_state(pid) + + {:ok, _} = MapDiscordNotification.update(notification, %{route_max_jumps: 2}) + + # A stored {:qualifying, 4} against the OLD threshold must not be compared + # against the new one โ€” it should reset, not silently suppress "opened". + RouteWatcher.notify(map.id) + Process.sleep(60) + # NOT `:unknown`: `start_evaluation/2` resets AND re-solves in the same + # pass, so this notify's still-cached 4-jump stub is re-evaluated + # immediately against the NEW max_jumps of 2 and disqualifies. The + # meaningful assertion is that it is anything other than the stale + # {:qualifying, 4}. + assert %{route_state: :none} = :sys.get_state(pid) + + # The stub must now be a route that qualifies under the NEW threshold. + # Leaving it at 4 jumps would be unreachable: `Evaluator` disqualifies + # jumps(4) > max_jumps(2), so the state could only ever reach `:none` and + # the "a fresh qualifying route still opens" half of this test would be + # asserting something that cannot happen. + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(2, 30_000_001)) + + RouteWatcher.notify(map.id) + Process.sleep(60) + assert %{route_state: {:qualifying, 2}} = :sys.get_state(pid) + end +``` + +- [ ] **Step 12: Run the test โ€” this step is verification-only, not RED/GREEN** +Run: `mix test test/unit/external_events/discord/route_watcher_test.exs` + +Both assertions are expected to **PASS** without new production code: the +solver-error case is correct by construction (`transition/3`'s `:unknown` clause +never touches `route_state`), and the config-version reset was implemented in +Step 8. This step exists to prove those two paths are actually covered by tests +rather than only by inspection. + +If either assertion fails, that is a real defect in Step 8's version check โ€” +fix `start_evaluation/2`, not the test. + +- [ ] **Step 13: Commit** +```bash +git add test/unit/external_events/discord/route_watcher_test.exs +git commit -m "test(discord): cover solver-error and config-version-mismatch state handling" +``` + +- [ ] **Step 14: Failing test โ€” a notify mid-solve is received and sets rerun?, and the stale result is discarded** + +This is the test that fails under a blocking `Task.yield` โ€” see the design +doc's "Data flow" step 6. It needs a solver stub that blocks until told to +proceed, so the test can notify while a task is provably in flight: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.RouteWatcherTest.BlockingSolver do + @moduledoc "Blocks until released via a message to the task's own pid, then returns the seeded result." + def find_strict(map_id, hubs, origin, settings, hubs_limit_reached?) do + receive do + :release -> :ok + end + + WandererApp.ExternalEvents.Discord.RouteWatcherTest.StubSolver.find_strict( + map_id, + hubs, + origin, + settings, + hubs_limit_reached? + ) + end +end +``` + +```elixir + test "a notify delivered while a solve is in flight sets rerun? and the stale result is discarded", + %{map: map, pid: pid} do + Application.put_env( + :wanderer_app, + :route_alert_solver, + WandererApp.ExternalEvents.Discord.RouteWatcherTest.BlockingSolver + ) + + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(4, 30_000_001)) + + RouteWatcher.notify(map.id) + # Give the task time to start and block inside `receive`, but not to finish. + Process.sleep(30) + assert %{task: %Task{}} = :sys.get_state(pid) + + # THE assertion that fails under Task.yield(20_000): a blocking watcher + # cannot process this cast at all until the yield times out or returns. + RouteWatcher.notify(map.id) + assert %{rerun?: true} = :sys.get_state(pid) + + # Release the blocked task. Its answer must be discarded โ€” a fresh solve + # starts instead โ€” so route_state must NOT become {:qualifying, 4} from + # THIS answer. Assert indirectly: after release, a second answer of 2 + # jumps is what should land, proving the first was thrown away. + Application.put_env( + :wanderer_app, + :route_alert_solver, + WandererApp.ExternalEvents.Discord.RouteWatcherTest.StubSolver + ) + + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(2, 30_000_001)) + + %{task: task} = :sys.get_state(pid) + send(task.pid, :release) + + Process.sleep(60) + assert %{route_state: {:qualifying, 2}, rerun?: false} = :sys.get_state(pid) + end +``` + +- [ ] **Step 15: Run test to verify it fails** +Run: `mix test test/unit/external_events/discord/route_watcher_test.exs` +Expected: the `rerun?: true` assertion should already PASS from Step 3's +`handle_cast(:notify, %{task: task} = state) when not is_nil(task)` clause โ€” +this step's actual new coverage is the discard-and-restart path. If the final +`route_state` assertion FAILS, confirm `land_result/2`'s `rerun?` branch calls +`start_evaluation/1` rather than `transition/3` (Step 8); fix and re-run. + +- [ ] **Step 16: Commit** +```bash +git add lib/wanderer_app/external_events/discord/route_watcher.ex test/unit/external_events/discord/route_watcher_test.exs +git commit -m "test(discord): a notify mid-solve sets rerun and discards the stale result" +``` + +- [ ] **Step 17: Failing test โ€” the 20s deadline shuts the task down without crashing the watcher, and restart rehydration** + +```elixir + test "the task deadline shuts the task down without crashing the watcher", %{map: map} do + Application.put_env( + :wanderer_app, + :route_alert_solver, + WandererApp.ExternalEvents.Discord.RouteWatcherTest.BlockingSolver + ) + + {:ok, pid} = start_watcher(map.id, task_timeout_ms: 30) + + RouteWatcher.notify(map.id) + Process.sleep(60) + + assert Process.alive?(pid) + assert %{task: nil, task_deadline_ref: nil} = :sys.get_state(pid) + end + + test "restart rehydrates from Cachex so a still-open route is not re-announced", %{map: map} do + Application.put_env(:wanderer_app, :route_alert_stub_result, qualifying_result(4, 30_000_001)) + {:ok, pid} = start_watcher(map.id) + RouteWatcher.notify(map.id) + Process.sleep(60) + assert %{route_state: {:qualifying, 4}} = :sys.get_state(pid) + + GenServer.stop(pid, :normal) + {:ok, pid2} = start_watcher(map.id) + + assert %{route_state: {:qualifying, 4}} = :sys.get_state(pid2) + end +``` + +- [ ] **Step 18: Run test to verify it passes** +Run: `mix test test/unit/external_events/discord/route_watcher_test.exs` +Expected: PASS for both if Steps 3โ€“8 were implemented as written (the deadline +handler and `rehydrate/1` already exist); if the deadline test fails, confirm +`Task.shutdown/2` is called with the `%Task{}` struct held in `state.task`, not +a bare reference โ€” `Task.shutdown/2` requires the struct, not the ref. + +- [ ] **Step 19: Commit** +```bash +git add test/unit/external_events/discord/route_watcher_test.exs +git commit -m "test(discord): cover the 20s solve deadline and restart rehydration" +``` + +- [ ] **Step 20: Run the full test file and the project checks** +Run: `mix test test/unit/external_events/discord/route_watcher_test.exs && mix format --check-formatted lib/wanderer_app/external_events/discord/route_watcher.ex && mix credo lib/wanderer_app/external_events/discord/route_watcher.ex` +Expected: all green. Fix any formatting or Credo findings and commit as a +follow-up `style:` commit before moving to Task 8. + +--- + +### Task 8: `Discord.RouteWatcherSupervisor` + application wiring + +**Files:** +- Create: `lib/wanderer_app/external_events/discord/route_watcher_supervisor.ex` +- Modify: `lib/wanderer_app/application.ex:141-154` (new Cachex worker), + `lib/wanderer_app/application.ex:265-282` (webhooks_enabled service list) +- Modify: `lib/wanderer_app/api/map_discord_notification.ex:178-193` (`after_destroy`) +- Test: `test/unit/external_events/discord/route_watcher_supervisor_test.exs` + +**Interfaces:** +- Consumes: `Discord.RouteWatcher.registry/0`, `Discord.RouteWatcher.start_link/1` + (Task 7). +- Produces: `notify(binary()) :: :ok`, `stop_watcher(binary()) :: :ok`. + +Mirrors `Discord.WorkerSupervisor` closely (`worker_supervisor.ex`): a +`Registry` plus a `DynamicSupervisor` under `:rest_for_one`, for the identical +reason stated in that module's comment (`worker_supervisor.ex:38-42`) โ€” a +Registry crash would leave running watchers alive but unreachable, and the +next `notify/1` would start a second watcher for the same map racing the +orphan; restarting the dynamic supervisor after the Registry clears that. No +`Task.Supervisor` child here: `Discord.RouteWatcher` reuses the one +`WorkerSupervisor` already starts (`worker_supervisor.ex:34`), since it is a +shared, unbounded task pool, not a per-feature resource. + +`notify/1` guards `Process.whereis(@registry)` exactly as +`WorkerSupervisor.ensure_worker/1` does (`worker_supervisor.ex:112-120`): a +no-op, not a crash, when webhooks are globally disabled and this supervisor +was never started โ€” `DiscordDispatcher`'s new topology clause (Task 9) must be +able to call this unconditionally. + +- [ ] **Step 1: Write the failing test** + +```elixir +defmodule WandererApp.ExternalEvents.Discord.RouteWatcherSupervisorTest do + use ExUnit.Case, async: false + + alias WandererApp.ExternalEvents.Discord.{RouteWatcher, RouteWatcherSupervisor} + + setup do + start_supervised!(RouteWatcherSupervisor) + :ok + end + + test "notify starts a watcher on demand" do + map_id = Ecto.UUID.generate() + assert :ok = RouteWatcherSupervisor.notify(map_id) + assert [{_pid, _}] = Registry.lookup(RouteWatcher.registry(), map_id) + end + + test "two notifies for one map reuse one watcher" do + map_id = Ecto.UUID.generate() + RouteWatcherSupervisor.notify(map_id) + [{pid1, _}] = Registry.lookup(RouteWatcher.registry(), map_id) + + RouteWatcherSupervisor.notify(map_id) + [{pid2, _}] = Registry.lookup(RouteWatcher.registry(), map_id) + + assert pid1 == pid2 + end + + test "stop_watcher removes the running watcher" do + map_id = Ecto.UUID.generate() + RouteWatcherSupervisor.notify(map_id) + assert [{pid, _}] = Registry.lookup(RouteWatcher.registry(), map_id) + + assert :ok = RouteWatcherSupervisor.stop_watcher(map_id) + refute Process.alive?(pid) + assert [] = Registry.lookup(RouteWatcher.registry(), map_id) + end + + test "notify is a no-op when the supervisor tree is not running" do + stop_supervised!(RouteWatcherSupervisor) + map_id = Ecto.UUID.generate() + assert :ok = RouteWatcherSupervisor.notify(map_id) + end + + test "stop_watcher is a no-op when the supervisor tree is not running" do + stop_supervised!(RouteWatcherSupervisor) + assert :ok = RouteWatcherSupervisor.stop_watcher(Ecto.UUID.generate()) + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** +Run: `mix test test/unit/external_events/discord/route_watcher_supervisor_test.exs` +Expected: FAIL โ€” `module WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor is not available`. + +- [ ] **Step 3: Implement** + +```elixir +defmodule WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor do + @moduledoc """ + Starts one `Discord.RouteWatcher` per map on demand, addressed through the + Registry `RouteWatcher` owns (`RouteWatcher.registry/0`). Mirrors + `Discord.WorkerSupervisor` closely; see that module's moduledoc for the + `:rest_for_one` reasoning, which applies identically here. + + Only started when webhooks are globally enabled (`application.ex`), so + `notify/1` and `stop_watcher/1` guard `Process.whereis/1` exactly as + `WorkerSupervisor` does โ€” a no-op when this tree is not running, never a + crash, so callers on the dispatch and resource-destroy paths do not need to + know whether the feature is enabled. + """ + + use Supervisor + + alias WandererApp.ExternalEvents.Discord.RouteWatcher + + @dyn_sup WandererApp.ExternalEvents.Discord.RouteWatcherDynamicSupervisor + @stop_timeout_ms 5_000 + + def start_link(opts \\ []), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + children = [ + {Registry, keys: :unique, name: RouteWatcher.registry()}, + {DynamicSupervisor, name: @dyn_sup, strategy: :one_for_one} + ] + + Supervisor.init(children, strategy: :rest_for_one) + end + + @doc "Starts the map's watcher if needed, then forwards the notify." + @spec notify(binary()) :: :ok + def notify(map_id) do + case Process.whereis(RouteWatcher.registry()) do + nil -> + :ok + + _ -> + with {:ok, _pid} <- ensure_watcher(map_id) do + RouteWatcher.notify(map_id) + end + + :ok + end + end + + @doc "Stops the map's watcher if one is running, discarding its state." + @spec stop_watcher(binary()) :: :ok + def stop_watcher(map_id) do + case Process.whereis(RouteWatcher.registry()) do + nil -> + :ok + + _ -> + case Registry.lookup(RouteWatcher.registry(), map_id) do + [{pid, _}] -> try_stop(pid) + [] -> :ok + end + + :ok + end + end + + defp try_stop(pid) do + GenServer.stop(pid, :normal, @stop_timeout_ms) + catch + :exit, _ -> :ok + end + + defp ensure_watcher(map_id) do + case Registry.lookup(RouteWatcher.registry(), map_id) do + [{pid, _}] when is_pid(pid) -> + if Process.alive?(pid), do: {:ok, pid}, else: start_watcher(map_id) + + [] -> + start_watcher(map_id) + end + end + + defp start_watcher(map_id) do + spec = {RouteWatcher, map_id: map_id} + + case DynamicSupervisor.start_child(@dyn_sup, spec) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + error -> error + end + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** +Run: `mix test test/unit/external_events/discord/route_watcher_supervisor_test.exs` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add lib/wanderer_app/external_events/discord/route_watcher_supervisor.ex test/unit/external_events/discord/route_watcher_supervisor_test.exs +git commit -m "feat(discord): add RouteWatcherSupervisor" +``` + +- [ ] **Step 6: Wire the Cachex worker and the supervisor into `application.ex`** + +`route_watcher.ex` persists to `:discord_route_alert_cache`, which does not +exist yet โ€” add it next to `:discord_dedup_cache` (both are Discord-feature +caches, started unconditionally like every other Cachex worker in this list, +since a watcher can be started and stopped independently of the rest of the +Discord supervision tree during tests): + +```elixir + # Route-alert state per map: {route_state, config_version}. No TTL โ€” + # unlike the dedup cache this is not a replay window, it is the last + # known state of an ongoing situation, and must survive as long as the + # map's route-alert configuration does. + Supervisor.child_spec( + {Cachex, name: :discord_route_alert_cache}, + id: :discord_route_alert_cache_worker + ), +``` + +placed immediately after the `:discord_dedup_cache` entry +(`application.ex:150-153`). Then insert the supervisor into the gated list, +BEFORE `DiscordDispatcher` โ€” it reads route-alert config synchronously +(`fetch_config/1`) but must never find the watcher tree missing when it calls +`RouteWatcherSupervisor.notify/1`: + +```elixir + [ + WandererApp.ExternalEvents.WebhookDispatcher, + WandererApp.ExternalEvents.Discord.VoiceGateway, + WandererApp.ExternalEvents.Discord.WorkerSupervisor, + # Route-alert watchers post through WorkerSupervisor, so this + # comes after it. Before DiscordDispatcher, whose new topology + # clause (Task 9) calls RouteWatcherSupervisor.notify/1 and must + # never find the tree missing. + WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor, + WandererApp.ExternalEvents.DiscordDispatcher + ] +``` + +This step has no isolated test of its own โ€” Task 9's dispatcher tests and any +existing application-boot smoke test are the verification. Confirm the app +still boots: + +Run: `mix compile --warnings-as-errors` +Expected: clean compile, no warnings. + +- [ ] **Step 7: Commit** +```bash +git add lib/wanderer_app/application.ex +git commit -m "feat(discord): wire RouteWatcherSupervisor and its Cachex worker into the app tree" +``` + +- [ ] **Step 8: Failing test โ€” the notification's destroy stops its map's watcher** + +```elixir + test "destroying the notification stops its map's route watcher", %{map: map, notification: notification} do + start_supervised!(RouteWatcherSupervisor) + RouteWatcherSupervisor.notify(map.id) + assert [{pid, _}] = Registry.lookup(RouteWatcher.registry(), map.id) + + :ok = MapDiscordNotification.destroy(notification) + + refute Process.alive?(pid) + end +``` + +Add this to `test/unit/external_events/discord/route_watcher_supervisor_test.exs` +in a `describe "resource integration"` block with its own `setup` inserting a +`Factory.insert(:map, %{})` and `MapDiscordNotification.create/1` (mirroring +the fixtures already used in `worker_test.exs:16-22`). + +- [ ] **Step 9: Run test to verify it fails** +Run: `mix test test/unit/external_events/discord/route_watcher_supervisor_test.exs` +Expected: FAIL โ€” the watcher process is still alive after destroy; `after_destroy/3` does not yet call `stop_watcher/1`. + +- [ ] **Step 10: Wire `stop_watcher/1` into the notification's destroy path** + +```elixir + @doc false + def after_destroy(changeset, {:ok, record}, _context) do + WandererApp.ExternalEvents.DiscordDispatcher.invalidate_cache(record.map_id) + + changeset.context + |> Map.get(:webhook_ids, []) + |> Enum.each(fn id -> + WandererApp.ExternalEvents.Discord.WorkerSupervisor.stop_worker(id) + end) + + # Stops the map's route-alert watcher too: without this a deleted + # notification's watcher keeps its debounce timer and Cachex-persisted + # state alive indefinitely, and a later `MapDiscordNotification.create/1` + # for the same map would resume against stale route_state instead of + # starting fresh at :unknown. + WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor.stop_watcher(record.map_id) + + {:ok, record} + end +``` + +Modify `lib/wanderer_app/api/map_discord_notification.ex:178-193` โ€” the single +line above the closing `{:ok, record}`. + +- [ ] **Step 11: Run test to verify it passes** +Run: `mix test test/unit/external_events/discord/route_watcher_supervisor_test.exs` +Expected: PASS. + +- [ ] **Step 12: Run the existing notification resource tests to confirm no regression** +Run: `mix test test/unit/api/map_discord_notification_test.exs` (adjust path if +the actual test file differs โ€” confirm with `git grep -l MapDiscordNotification test/` +before running) +Expected: PASS, unchanged. + +- [ ] **Step 13: Commit** +```bash +git add lib/wanderer_app/api/map_discord_notification.ex test/unit/external_events/discord/route_watcher_supervisor_test.exs +git commit -m "fix(discord): stop a map's route watcher when its notification is destroyed" +``` + +--- + +### Task 9: `DiscordDispatcher` topology clause + +**Files:** +- Modify: `lib/wanderer_app/external_events/discord_dispatcher.ex:272` (new + clause, inserted immediately before the catch-all) +- Test: `test/unit/external_events/discord_dispatcher_test.exs` (new `describe` + block; existing file, do not restructure the rest of it) + +**Interfaces:** +- Consumes: `WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor.notify/1` + (Task 8), the existing `enabled_globally?/0` and `fetch_config/1` private + helpers (`discord_dispatcher.ex:756-772`). +- Produces: nothing new โ€” this is a private `do_dispatch/2` clause. + +`DiscordDispatcher` is a singleton GenServer; every map's kill batches funnel +through this one process (see its moduledoc and the enrichment budget +comments at `discord_dispatcher.ex:299-315`). This clause must do **no DB and +no HTTP work** of its own: `fetch_config/1` reads the already-warm +`:discord_notification_cache` (a cache miss here is one Ecto query, same cost +the kill path already pays on every cache-cold map, not a new class of cost), +and `RouteWatcherSupervisor.notify/1` is a cast into a different process. Any +heavier work โ€” the solve, the embed, the HTTP post โ€” belongs entirely to +`Discord.RouteWatcher` (Task 7), which is one GenServer per *map*, not the one +singleton every map shares. Adding synchronous or per-event work to this +clause would reintroduce exactly the head-of-line blocking the enrichment +budget comment warns about, for every map's kill notifications, not just +route alerts. + +Event types confirmed against `event.ex:94,101,103`: `:add_system`, +`:connection_added`, `:connection_updated` (`:connection_removed` is +deliberately excluded โ€” removing a connection cannot open a new route, only +close one, and a closed route already clears silently per the transition +table; evaluating on it would be pure wasted solver load). + +- [ ] **Step 1: Write the failing tests** + +Add to `test/unit/external_events/discord_dispatcher_test.exs`, in a new +`describe "route alert dispatch" do ... end` block. Needs a stand-in for +`RouteWatcherSupervisor.notify/1` observable from the test process, following +the same pattern `Enricher`/`TickerEnricher` use at the top of the file (a +named process receiving a message, not Mox โ€” `notify/1` would run from the +dispatcher's own process, and a plain function call cannot be asserted on +directly without either a mock or an observer): + +```elixir +defmodule WandererApp.ExternalEvents.DiscordDispatcherTest.RouteWatcherObserver do + @moduledoc "Stands in for RouteWatcherSupervisor.notify/1 so tests can assert it was called." + def notify(map_id) do + case Process.whereis(:route_watcher_observer) do + nil -> :ok + pid -> send(pid, {:route_notify, map_id}) + end + + :ok + end +end +``` + +```elixir + describe "route alert dispatch" do + setup %{map: map, notification: notification} do + Application.put_env( + :wanderer_app, + :route_watcher_supervisor, + WandererApp.ExternalEvents.DiscordDispatcherTest.RouteWatcherObserver + ) + + on_exit(fn -> Application.delete_env(:wanderer_app, :route_watcher_supervisor) end) + + Process.register(self(), :route_watcher_observer) + on_exit(fn -> Process.unregister(:route_watcher_observer) end) + + {:ok, notification} = + MapDiscordNotification.update(notification, %{ + route_alerts_enabled?: true, + home_system_id: 30_000_001 + }) + + DiscordDispatcher.invalidate_cache(map.id) + %{notification: notification} + end + + for type <- [:add_system, :connection_added, :connection_updated] do + test "#{type} notifies the route watcher", %{map: map} do + event = Event.new(map.id, unquote(type), %{}) + DiscordDispatcher.dispatch_event(map.id, event) + + assert_receive {:route_notify, map_id}, 500 + assert map_id == map.id + end + end + + test "no notify when webhooks are globally disabled", %{map: map} do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, :webhooks_enabled, false) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + + event = Event.new(map.id, :add_system, %{}) + DiscordDispatcher.dispatch_event(map.id, event) + + refute_receive {:route_notify, _}, 200 + end + + test "no notify when route_alerts_enabled? is false", %{map: map, notification: notification} do + {:ok, _} = MapDiscordNotification.update(notification, %{route_alerts_enabled?: false}) + DiscordDispatcher.invalidate_cache(map.id) + + event = Event.new(map.id, :add_system, %{}) + DiscordDispatcher.dispatch_event(map.id, event) + + refute_receive {:route_notify, _}, 200 + end + + test "no notify when home_system_id is nil", %{map: map, notification: notification} do + {:ok, _} = MapDiscordNotification.update(notification, %{home_system_id: nil}) + DiscordDispatcher.invalidate_cache(map.id) + + event = Event.new(map.id, :add_system, %{}) + DiscordDispatcher.dispatch_event(map.id, event) + + refute_receive {:route_notify, _}, 200 + end + + test "no notify for a map with no Discord configuration at all" do + map = Factory.insert(:map, %{}) + event = Event.new(map.id, :add_system, %{}) + DiscordDispatcher.dispatch_event(map.id, event) + + refute_receive {:route_notify, _}, 200 + end + + test "the kill path is unaffected", %{map: map, system: w} do + # Regression guard, not new behaviour: re-run an existing kill-delivery + # scenario inside this describe block to confirm the new clause + # (inserted before the catch-all) does not shadow :map_kill. Use the + # file's own killmail/2 and wait_for_requests/1 helpers โ€” confirm their + # exact names with + # `grep -n "defp wait_for\|defp killmail" test/unit/external_events/discord_dispatcher_test.exs` + # before writing this test, since the plan's names here are best-effort. + DiscordDispatcher.dispatch_event( + map.id, + Event.new(map.id, :map_kill, %{ + "type" => :killmail_update, + "solar_system_id" => 30_000_142, + "killmails" => [killmail(1)] + }) + ) + + assert [_] = wait_for_requests(1) + end + end +``` + +- [ ] **Step 2: Run test to verify it fails** +Run: `mix test test/unit/external_events/discord_dispatcher_test.exs` +Expected: FAIL on every `assert_receive {:route_notify, ...}` โ€” `do_dispatch/2` +still falls through to the catch-all for all three topology event types. + +- [ ] **Step 3: Implement the clause** + +```elixir + # No DB or HTTP work of its own: fetch_config/1 reads the already-cached + # notification (a cache miss costs one Ecto query, same as the kill path + # pays on any cache-cold map), and RouteWatcherSupervisor.notify/1 is a cast + # into a different process. This dispatcher is a SINGLETON shared by every + # map's kill batches โ€” the solve, the embed, and the HTTP post all belong to + # Discord.RouteWatcher, one GenServer per map, never to this clause. + defp do_dispatch(map_id, %{type: type}) + when type in [:add_system, :connection_added, :connection_updated] do + with true <- enabled_globally?(), + {:ok, notification} <- fetch_config(map_id), + true <- notification.route_alerts_enabled?, + home_system_id when not is_nil(home_system_id) <- notification.home_system_id do + route_watcher_supervisor().notify(map_id) + end + + :ok + end + + defp route_watcher_supervisor, + do: + Application.get_env( + :wanderer_app, + :route_watcher_supervisor, + WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor + ) +``` + +Insert this clause immediately before `defp do_dispatch(_map_id, _event), do: :ok` +at `discord_dispatcher.ex:272` โ€” clause order matters, since Elixir matches +top-down and the catch-all would otherwise swallow these three event types +first. + +- [ ] **Step 4: Run test to verify it passes** +Run: `mix test test/unit/external_events/discord_dispatcher_test.exs` +Expected: PASS โ€” all six new assertions and every pre-existing test in the +file (in particular the kill-path regression test). + +- [ ] **Step 5: Run the full dispatcher and route-watcher suites together** +Run: `mix test test/unit/external_events/discord_dispatcher_test.exs test/unit/external_events/discord/route_watcher_test.exs test/unit/external_events/discord/route_watcher_supervisor_test.exs` +Expected: all green. + +- [ ] **Step 6: Format and static checks** +Run: `mix format --check-formatted lib/wanderer_app/external_events/discord_dispatcher.ex && mix credo lib/wanderer_app/external_events/discord_dispatcher.ex` +Expected: clean. Fix and re-run if not. + +- [ ] **Step 7: Commit** +```bash +git add lib/wanderer_app/external_events/discord_dispatcher.ex test/unit/external_events/discord_dispatcher_test.exs +git commit -m "feat(discord): dispatch topology events to RouteWatcherSupervisor" +``` + +--- + +# Part 05 โ€” Settings UI for route alerts + +Depends on Task 3 (`route_alerts_enabled?`, `home_system_id`, `route_max_jumps` +on `MapDiscordNotification`; `mention_targets` and the `:route` role on +`MapDiscordWebhook`) and Task 4 (`Mentions.valid_target?/1`) landing first โ€” +this task cannot compile or pass a single test before both exist. It is purely +additive to `MapNotificationsComponent`; nothing here changes kill-notification +behaviour for the `:system` or `:character` rows. + +**Cross-task accept-list note.** Task 3 owns both resources' `accept` lists; +this task only *verifies* them (Step 4) and never edits them. Two tasks editing +the same list risks one silently clobbering `MapDiscordNotification`'s explicit +`:update` accept list, which exists specifically to stop `:map_id` being +re-parented. + +### Task 10: Route-alert fields, home system, and mention targets on the notifications settings tab + +**Files:** +- Modify: `lib/wanderer_app_web/live/maps/components/map_notifications_component.ex:1-921` +- Test: `test/wanderer_app_web/live/map_notifications_test.exs` + +(Both Ash resources are read-only here โ€” their accept lists belong to Task 3.) + +**Interfaces:** +- Consumes: + - `WandererApp.Api.MapDiscordNotification.update/2` (existing code interface, extended attrs). + - `WandererApp.Api.MapDiscordWebhook.create/1`, `.update/2` (existing code interfaces, extended attrs โ€” contract Task 3). + - `WandererApp.ExternalEvents.Discord.Mentions.valid_target?/1` (contract Task 4) โ€” the *only* mention-format check this task performs; it must not reimplement the regex. +- Produces: no new public interface. Purely a LiveView settings surface over the Task 3 schema. + +**Documented simplifications (read before implementing):** + +1. **`home_system_id` is a plain number input, not a name-search picker.** This + file already has a system picker (`live_select` + `search_systems/2` for + "excluded systems"), but that picker's whole interaction model is + *add-to-a-list* โ€” pick one, it appends, the field clears for the next pick. + Home system is a single replace-on-select value, which is a different + interaction the existing picker was not built for, and building that + variant is out of scope here. The field is a numeric solar-system-id input + with placeholder text; no name is resolved or displayed next to it. If a + future task adds a single-value system picker (e.g. for `Map.hubs`), this + field should switch to it. +2. **Fields are hidden with a CSS class, never `disabled` or `:if`-removed.** + A native `disabled` input, and a `:if`-removed one, are both **excluded + from the submitted form params entirely**. If `home_system_id` were hidden + that way while the toggle is off, every save would submit no value for it + at all โ€” losing whatever the user typed the moment they unchecked the box, + and (worse) making it impossible for the Ash "home system required when + enabled" validation to ever see a submitted value on the very save that + turns the toggle on, because the field would not exist in the DOM until + *after* that save round-trips. A CSS-hidden field keeps posting its value + regardless of visibility, so toggling is purely cosmetic and never eats + data. +3. **The mention-targets input is rendered on every webhook row, not only + `:route`**, but visually hidden via the same CSS-class technique for + `:system` and `:character` โ€” again so its param key is always present and + `save_webhook/4` needs only one code path rather than one that + conditionally omits the key. Neither of those two roles has ever had a way + to set `mention_targets` before this task, so a hidden, always-empty input + for them is inert, not a regression. +4. **The "inline error" for an invalid mention target reuses this component's + existing single error banner** (`@error`, rendered once above the main + form) rather than inventing a new per-field error slot. Every other + validation failure in this file โ€” a bad webhook URL, a non-numeric + corporation id, a stale record โ€” already reports through that one banner, + and adding a second, differently-styled error mechanism for just this one + field would be the inconsistency, not the fix. "Inline" in the task + description is read here as "reported to the user in the same request, + distinct per bad entry" (as opposed to silently dropping invalid entries + from the list), which the banner satisfies. +5. **`route_max_jumps`'s 1โ€“20 bound is enforced only as HTML `min`/`max` + here.** The contract's Task 3 attribute snippet does not show a + `constraints: [min: 1, max: 20]` on the column. If Task 3 lands without + that constraint, this UI's bound is cosmetic only โ€” a hand-crafted form + post (or a future API caller) could still set 0 or 500. Flagging this + explicitly rather than assuming Task 3 covers it: **whoever lands last + between Task 3 and this task should confirm the Ash-level constraint + exists**; if not, add `constraints: [min: 1, max: 20]` to the attribute in + `map_discord_notification.ex` as a one-line follow-up, not silently skip it. + +- [ ] **Step 1: Confirm the Task 3 / Task 4 prerequisites are actually present** + +Run: `mix compile` and `grep -n "route_alerts_enabled?\|home_system_id\|route_max_jumps" lib/wanderer_app/api/map_discord_notification.ex` +Expected: the three attributes exist, and `grep -n "mention_targets\|:route" lib/wanderer_app/api/map_discord_webhook.ex` shows the new column and the extended `one_of`. If any is missing, stop โ€” this task cannot proceed until Task 3 lands. + +- [ ] **Step 2: Write the failing LiveView tests** + +Append to `test/wanderer_app_web/live/map_notifications_test.exs`, in a new +`describe` block (uses the file's existing `open_notifications/2`, +`notification_with_webhooks/2`, `system_webhook/1` helpers as-is): + +```elixir + describe "route alerts" do + test "enabling route alerts without a home system surfaces the Ash validation error", %{ + conn: conn, + map: map + } do + notification_with_webhooks(map, [:system]) + view = open_notifications(conn, map) + + html = + view + |> form("#discord-notification-form", %{ + "notification" => %{ + "enabled" => "true", + "wh_only" => "true", + "route_alerts_enabled" => "true", + "home_system_id" => "", + "route_max_jumps" => "5" + } + }) + |> render_submit() + + # Exact wording is Task 3's to define; this asserts on it because a + # substring match loose enough to survive any wording would also survive + # the validation being silently removed. If Task 3 ships different + # copy, update this one line to match it โ€” do not weaken the match. + assert html =~ "Home system is required to enable route alerts." + + assert {:ok, rec} = MapDiscordNotification.by_map(map.id) + refute rec.route_alerts_enabled? + assert rec.home_system_id == nil + end + + test "saving valid route settings persists the toggle, home system, and max jumps", %{ + conn: conn, + map: map + } do + notification_with_webhooks(map, [:system]) + view = open_notifications(conn, map) + + view + |> form("#discord-notification-form", %{ + "notification" => %{ + "enabled" => "true", + "wh_only" => "true", + "route_alerts_enabled" => "true", + "home_system_id" => "30000142", + "route_max_jumps" => "3" + } + }) + |> render_submit() + + assert {:ok, rec} = MapDiscordNotification.by_map(map.id) + assert rec.route_alerts_enabled? == true + assert rec.home_system_id == 30_000_142 + assert rec.route_max_jumps == 3 + end + + test "the route fields are hidden while the toggle is off, not removed from the form", %{ + conn: conn, + map: map + } do + notification_with_webhooks(map, [:system]) + view = open_notifications(conn, map) + + # Off by default (route_alerts_enabled? defaults to false per Task 3) โ€” + # the wrapper carries the "hidden" class, and the inputs are still + # present in the DOM so their values still post on save. + assert has_element?(view, "div.hidden input[name='notification[home_system_id]']") + + view + |> element("input[name='notification[route_alerts_enabled]'][type='checkbox']") + |> render_change(%{"notification" => %{"route_alerts_enabled" => "true"}}) + + refute has_element?(view, "div.hidden input[name='notification[home_system_id]']") + end + + test "the route webhook url can be added", %{conn: conn, map: map} do + rec = notification_with_webhooks(map, [:system]) + view = open_notifications(conn, map) + + view + |> form("#webhook-form-route", %{ + "webhook" => %{"webhook_url" => "https://discord.com/api/webhooks/999/routetok"} + }) + |> render_submit() + + {:ok, webhooks} = MapDiscordWebhook.by_notification(rec.id) + assert %{role: :route, enabled?: true} = Enum.find(webhooks, &(&1.role == :route)) + assert has_element?(view, "#webhook-row-route button[phx-click='remove-webhook']") + end + + test "an invalid mention target shows an inline error and does not persist the webhook", %{ + conn: conn, + map: map + } do + rec = notification_with_webhooks(map, [:system]) + view = open_notifications(conn, map) + + html = + view + |> form("#webhook-form-route", %{ + "webhook" => %{ + "webhook_url" => "https://discord.com/api/webhooks/999/routetok", + "mention_targets" => "role:123456789012345678, not-a-target" + } + }) + |> render_submit() + + assert html =~ "not-a-target" + assert html =~ "not a valid mention target" + + {:ok, webhooks} = MapDiscordWebhook.by_notification(rec.id) + refute Enum.any?(webhooks, &(&1.role == :route)) + end + + test "valid mention targets are saved, comma-separated and trimmed", %{conn: conn, map: map} do + rec = notification_with_webhooks(map, [:system]) + view = open_notifications(conn, map) + + view + |> form("#webhook-form-route", %{ + "webhook" => %{ + "webhook_url" => "https://discord.com/api/webhooks/999/routetok", + "mention_targets" => "role:123456789012345678, user:234567890123456789 " + } + }) + |> render_submit() + + {:ok, webhooks} = MapDiscordWebhook.by_notification(rec.id) + route_wh = Enum.find(webhooks, &(&1.role == :route)) + assert route_wh.mention_targets == ["role:123456789012345678", "user:234567890123456789"] + end + end +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `mix test test/wanderer_app_web/live/map_notifications_test.exs` +Expected: every test in the new `describe "route alerts"` block FAILs โ€” either +a `KeyError`/`ArgumentError` from `.input field={f[:route_alerts_enabled]}` +not existing on the form, or (once the template compiles) a `NoSuchInput` +Ash error, since the component does not yet build or accept any of these +attrs. Every pre-existing test in the file must still PASS unmodified โ€” this +step also serves as the regression baseline. + +- [ ] **Step 4: Verify Task 3 already whitelisted the new attributes โ€” do not re-edit** + +Task 3 owns both resources' accept lists. Its plan section adds the three new +`MapDiscordNotification` attributes to `default_accept` and to the explicit +`:update` accept list, and adds `:mention_targets` to `MapDiscordWebhook`'s +`default_accept` and `:update` accept list. Confirm rather than duplicate: + +```bash +grep -A9 'default_accept' lib/wanderer_app/api/map_discord_notification.ex +grep -A9 'accept \[' lib/wanderer_app/api/map_discord_notification.ex +grep -n 'default_accept\|accept \[' lib/wanderer_app/api/map_discord_webhook.ex +``` + +Expected: `:route_alerts_enabled?`, `:home_system_id`, `:route_max_jumps` in +**both** `MapDiscordNotification` lists, and `:mention_targets` in **both** +`MapDiscordWebhook` lists. + +If any are missing, Task 3 is incomplete โ€” go fix it there and re-run Task 3's +tests. Do not patch the accept lists from this task: two tasks editing the same +list is how one of them silently loses the explicit `:update` list that exists +to stop `:map_id` being re-parented. + +- [ ] **Step 6: Extend the roles and role parsing** + +```elixir + @roles [:system, :character, :route] +``` + +```elixir + defp parse_role("character"), do: :character + defp parse_role(:character), do: :character + defp parse_role("route"), do: :route + defp parse_role(:route), do: :route + defp parse_role(_), do: :system +``` + +Extend `remove-webhook`'s guard โ€” `:route` is removable the same way +`:character` is, since a removed `:route` destination simply falls back to +`:system` per the design's Router rule, exactly like today's `:character` +removal has no fallback of its own to break: + +```elixir + def handle_event("remove-webhook", %{"role" => role}, socket) do + role = parse_role(role) + + case {role, socket.assigns.webhooks[role]} do + {role, %{} = webhook} when role in [:character, :route] -> + case MapDiscordWebhook.destroy(webhook) do + :ok -> + {:noreply, + socket + |> assign_notification(reload_notification(socket.assigns.map_id)) + |> assign(:error, nil) + |> assign(:flash_message, "#{role_label(role)} destination removed.")} + + {:error, error} -> + {:noreply, + socket |> assign(:error, humanize_error(error)) |> assign(:flash_message, nil)} + end + + _ -> + {:noreply, assign(socket, :error, "The system destination cannot be removed.")} + end + end +``` + +```elixir + defp role_label(:character), do: "Character" + defp role_label(:route), do: "Route" +``` + +- [ ] **Step 7: Add the `toggle-route-alerts` event handler** + +Placed next to `handle_event("replace-url", ...)`, since both are +display-state-only handlers that never touch the database: + +```elixir + # Purely client-display state: which fields are visually shown, driven by + # the CHECKBOX's own `phx-change` (not the whole form's) so ticking or + # unticking this one box is the only thing that round-trips โ€” typing in the + # numeric fields below it does not. Nothing here is persisted; the actual + # value is only ever written by "save", same as every other field on this + # form. + def handle_event("toggle-route-alerts", %{"notification" => params}, socket) do + {:noreply, assign(socket, :route_toggle, checked?(params["route_alerts_enabled"]))} + end +``` + +- [ ] **Step 8: Extend `handle_event("save", ...)`'s attrs and add the parsers** + +```elixir + def handle_event("save", %{"notification" => params}, socket) do + attrs = %{ + wh_only: checked?(params["wh_only"]), + enabled?: checked?(params["enabled"]), + route_alerts_enabled?: checked?(params["route_alerts_enabled"]), + home_system_id: parse_home_system_id(params["home_system_id"]), + route_max_jumps: parse_route_max_jumps(params["route_max_jumps"]) + } + + # ... unchanged create/update dispatch below this line +``` + +```elixir + # Blank or non-numeric input clears the home system rather than raising โ€” + # the Ash "required when enabled" validation is what reports that, not this + # parse step, matching how `add-excluded`/`add-focus-corp` already leave + # rejection to a later stage rather than crashing on bad input here. + defp parse_home_system_id(raw) do + case Integer.parse(to_string(raw || "")) do + {id, ""} -> id + _ -> nil + end + end + + # Falls back to the column default (5) on blank/non-numeric input rather + # than sending `nil` into an `allow_nil?: false` attribute, which Ash would + # reject outright. + defp parse_route_max_jumps(raw) do + case Integer.parse(to_string(raw || "")) do + {n, ""} -> n + _ -> 5 + end + end +``` + +- [ ] **Step 9: Extend `notification_form/1` and `assign_notification/2`** + +```elixir + defp notification_form(notification) do + to_form( + %{ + "webhook_url" => "", + "wh_only" => is_nil(notification) or notification.wh_only, + "enabled" => is_nil(notification) or notification.enabled?, + # Unlike wh_only/enabled, this one defaults OFF (Task 3: `default: + # false`) โ€” `is_nil(notification) or ...` would default it ON, which + # is backwards for this field. + "route_alerts_enabled" => !is_nil(notification) and notification.route_alerts_enabled?, + "home_system_id" => home_system_id_value(notification), + "route_max_jumps" => route_max_jumps_value(notification) + }, + as: :notification + ) + end + + defp home_system_id_value(nil), do: "" + defp home_system_id_value(%{home_system_id: nil}), do: "" + defp home_system_id_value(%{home_system_id: id}), do: to_string(id) + + defp route_max_jumps_value(nil), do: 5 + defp route_max_jumps_value(%{route_max_jumps: n}), do: n +``` + +In `assign_notification/2`, add the client-display toggle so it survives every +re-render (a webhook save, an excluded-system add, etc. must not silently +snap the route fields back to hidden while the user is mid-edit on something +else): + +```elixir + defp assign_notification(socket, notification) do + webhooks = load_webhooks(notification) + + socket + |> assign(:notification, notification) + |> assign(:webhooks, webhooks) + |> assign(:route_toggle, !is_nil(notification) and notification.route_alerts_enabled?) + |> assign(:excluded_systems, excluded_system_labels(notification)) + # ... rest unchanged +``` + +- [ ] **Step 10: Extend `webhook_forms/1` with `mention_targets`** + +```elixir + defp webhook_forms(webhooks) do + Map.new(@roles, fn role -> + webhook = Map.get(webhooks, role) + + form = + to_form( + %{ + "webhook_url" => "", + "enabled" => is_nil(webhook) or webhook.enabled?, + "mention_targets" => mention_targets_value(webhook) + }, + as: :webhook + ) + + {role, form} + end) + end + + defp mention_targets_value(nil), do: "" + defp mention_targets_value(%{mention_targets: targets}), do: Enum.join(targets, ", ") +``` + +- [ ] **Step 11: Parse and validate mention targets in `save_webhook/4`, consuming `Mentions.valid_target?/1`** + +```elixir + alias WandererApp.ExternalEvents.Discord.Mentions +``` + +```elixir + defp save_webhook(rec, nil, role, %{"webhook_url" => url} = params) + when is_binary(url) and url != "" do + with {:ok, targets} <- parse_mention_targets(params["mention_targets"]) do + MapDiscordWebhook.create(%{ + notification_id: rec.id, + role: role, + webhook_url: url, + mention_targets: targets + }) + end + end + + defp save_webhook(_rec, nil, _role, _params), do: {:error, "Enter a webhook URL first."} + + defp save_webhook(_rec, webhook, _role, %{"webhook_url" => url} = params) + when is_binary(url) and url != "" do + with {:ok, targets} <- parse_mention_targets(params["mention_targets"]) do + MapDiscordWebhook.update(webhook, %{ + webhook_url: url, + enabled?: checked?(params["enabled"]), + mention_targets: targets + }) + end + end + + # This branch used to call `MapDiscordWebhook.set_enabled/2`, whose accept + # list is `[:enabled?]` only. Now that this row can also carry + # `mention_targets`, it goes through the general `update` action instead so + # a mention-only edit (no URL change) still saves โ€” `set_enabled` itself is + # untouched and still used by other callers (see `router_test.exs`, + # `worker_test.exs`, etc.), this is only this handler's own dispatch. + defp save_webhook(_rec, webhook, _role, params) do + with {:ok, targets} <- parse_mention_targets(params["mention_targets"]) do + MapDiscordWebhook.update(webhook, %{ + enabled?: checked?(params["enabled"]), + mention_targets: targets + }) + end + end + + # Empty/whitespace entries are dropped silently โ€” that is not "the silent + # drop" the task warns against, which is about a MALFORMED entry (one that + # does not match `Mentions.valid_target?/1`) disappearing without telling + # the user. A blank entry from "role:123, " trailing-comma typing is not + # malformed input, it is nothing. + defp parse_mention_targets(raw) when is_binary(raw) do + targets = + raw + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + case Enum.find(targets, &(not Mentions.valid_target?(&1))) do + nil -> + {:ok, targets} + + bad -> + {:error, + "\"#{bad}\" is not a valid mention target. Use user: or role: with the " <> + "Discord id (17-20 digits) โ€” handles like @name do not work here."} + end + end + + defp parse_mention_targets(_), do: {:ok, []} +``` + +`handle_event("save-webhook", ...)`'s existing `with %{} = rec <- ..., +{:ok, _} <- save_webhook(...)` clause already routes a `{:error, message}` +return from `save_webhook/4` into `humanize_error/1` unchanged โ€” a plain +binary error hits `humanize_error/1`'s first clause and renders verbatim, the +same path the existing "Enter a webhook URL first." message already takes. No +change needed to `handle_event("save-webhook", ...)` itself. + +- [ ] **Step 12: Template โ€” route toggle, home system, max jumps on the main form** + +Insert directly after the existing `wh_only`/`enabled` inputs in +`discord-notification-form`: + +```heex + <.input field={f[:wh_only]} type="checkbox" label="Only wormhole kills" /> + <.input field={f[:enabled]} type="checkbox" label="Enabled for this map" /> + + <.input + field={f[:route_alerts_enabled]} + type="checkbox" + label="Route alerts (highsec route to Jita)" + phx-change="toggle-route-alerts" + /> + +
      + <.input + field={f[:home_system_id]} + type="number" + label="Home system (solar system ID)" + placeholder="e.g. 31000005" + /> + <.input + field={f[:route_max_jumps]} + type="number" + min="1" + max="20" + label="Max jumps to Jita (inclusive)" + /> +

      + Posts when a highsec-only route this length or shorter opens from + the home system to Jita. Wormhole hops on the way don't count + against "highsec" โ€” only k-space systems on the path do. Enter the + home system's numeric solar system ID; there is no name search for + this field yet. +

      +
      + + <.button type="submit" class="self-start">Save +``` + +- [ ] **Step 13: Template โ€” the `:route` webhook row and its mention field** + +Add a third `<.webhook_row>` call, after the existing `:character` one: + +```heex + <.webhook_row + :if={@notification} + role={:route} + title="Route alert channel (optional)" + help={ + "Receives an alert when a highsec-only route opens from the home system to Jita. " <> + "This message names every system on the route in order โ€” treat this channel as " <> + "trusted, there is no redacted version of it. Leave it unset and route alerts go " <> + "to the system channel instead." + } + webhook={@webhooks[:route]} + form={@webhook_forms[:route]} + replacing?={@replacing_url?[:route]} + removable?={true} + show_mentions?={true} + myself={@myself} + /> +``` + +Add the new attr and the mention field to the `webhook_row` component: + +```elixir + attr :role, :atom, required: true + attr :title, :string, required: true + attr :help, :string, required: true + attr :webhook, :any, required: true + attr :form, :any, required: true + attr :replacing?, :boolean, required: true + attr :removable?, :boolean, required: true + attr :show_mentions?, :boolean, default: false + attr :myself, :any, required: true +``` + +Inside the row's `<.form>`, directly after the `enabled` checkbox and before +the submit button: + +```heex + <.input :if={@webhook} field={wf[:enabled]} type="checkbox" label="Enabled" /> + +
      + <.input + field={wf[:mention_targets]} + type="text" + label="Mentions (optional)" + placeholder="role:123456789012345678, user:234567890123456789" + /> +

      + Comma-separated user:<id> or + role:<id> Discord snowflakes to ping when a route + opens. Handles like @name do not work โ€” Discord + requires the numeric id. Leave empty to post with no ping. +

      +
      + + <.button type="submit" class="self-start">{if @webhook, do: "Save", else: "Add"} +``` + +- [ ] **Step 14: Run the tests to verify they pass** + +Run: `mix test test/wanderer_app_web/live/map_notifications_test.exs` +Expected: PASS, the whole file โ€” every pre-existing test plus the six new +`describe "route alerts"` tests. If the toggle test in Step 2 fails on the +`render_change` payload shape, check that the checkbox's rendered `name` +attribute is `notification[route_alerts_enabled]` (it inherits this from +`f[:route_alerts_enabled]`) and adjust the test's nested map key to match โ€” +this is a test-only fix, not a template change. + +- [ ] **Step 15: Format and lint** + +Run: `mix format lib/wanderer_app_web/live/maps/components/map_notifications_component.ex lib/wanderer_app/api/map_discord_notification.ex lib/wanderer_app/api/map_discord_webhook.ex test/wanderer_app_web/live/map_notifications_test.exs` +Run: `mix credo lib/wanderer_app_web/live/maps/components/map_notifications_component.ex` +Expected: clean. + +- [ ] **Step 16: Commit** +```bash +git add lib/wanderer_app_web/live/maps/components/map_notifications_component.ex \ + lib/wanderer_app/api/map_discord_notification.ex \ + lib/wanderer_app/api/map_discord_webhook.ex \ + test/wanderer_app_web/live/map_notifications_test.exs +git commit -m "feat(discord): add route alert settings to the notifications tab + +Adds the route-alerts toggle, home system, and max-jumps fields to the +map's Discord notification form, plus a :route webhook row with a +mention-targets editor validated against Mentions.valid_target?/1. Home +system uses a plain numeric input rather than a name-search picker โ€” +the existing live_select picker in this file is add-to-a-list, not +select-one-and-replace, and building that variant is out of scope +here. Route fields hide via CSS rather than disabled/:if so their +values keep posting while the toggle is off." +``` diff --git a/docs/superpowers/plans/2026-08-07-discord-voice-mentions.md b/docs/superpowers/plans/2026-08-07-discord-voice-mentions.md new file mode 100644 index 000000000..53183cbe9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-discord-voice-mentions.md @@ -0,0 +1,1208 @@ +# Discord Voice-Participant Mentions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prepend `<@user_id>` mentions for everyone active in the configured Discord guild's voice channels onto kill notifications posted to a map's **system** webhook. + +**Architecture:** A Nostrum gateway bot (started only when `DISCORD_BOT_TOKEN` + `DISCORD_GUILD_ID` are configured) passively maintains voice states in ETS; a new pure `VoiceParticipants` module turns them into a budget-capped mention prefix; the existing `DiscordDispatcher.deliver_to/5` injects that prefix into the first message chunk for `:system`-role deliveries only. Every failure path degrades to "no mentions" โ€” voice tagging can never cost a kill notification. + +**Tech Stack:** Elixir/Phoenix, Nostrum ~> 0.10 (`runtime: false`), existing `WandererApp.ExternalEvents.Discord` pipeline, ExUnit. + +**Spec:** `docs/superpowers/specs/2026-08-07-discord-voice-mentions-design.md` + +## Global Constraints + +- Env vars: `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`. Feature enabled iff **both** are set and the guild id parses as a positive integer. No DB migration, no UI. +- Mentions on `:system`-role deliveries only; `:character` path byte-identical. +- Mention prefix budget: **1,800 characters** โ€” append whole mentions until the next would exceed it, silently drop the rest. +- Nostrum dep is `runtime: false` in `deps`, and `nostrum: :load` in the release `applications` list (a `runtime: false` dep is otherwise excluded from the release). +- Gateway intents: `[:guilds, :guild_voice_states]` (both non-privileged). +- Nostrum never starts under `mix test`; new pure-logic tests are `async: true`, tests touching application env are `async: false`. +- Stale-cache mentions during gateway reconnect are an accepted tradeoff (spec, Error handling) โ€” do not build a freshness signal. +- Zoo-fork feature: do not touch upstream-shared behavior beyond the listed files. +- Run `mix format` before every commit. + +--- + +### Task 1: Config surface and Nostrum dependency + +**Files:** +- Modify: `mix.exs` (deps ~line 60s; `releases` block at ~line 30) +- Modify: `config/runtime.exs` (`:external_events` block at ~line 495) +- Modify: `lib/wanderer_app/env.ex` (append near `discord_max_killmail_age_seconds/0`, ~line 127) +- Modify: `.env.example` (Discord section, if present; otherwise append) +- Test: `test/unit/env_discord_voice_test.exs` (create) + +**Interfaces:** +- Consumes: existing `get_var_from_path_or_env/2,3` helpers in `config/runtime.exs`; `@app` module attribute in `WandererApp.Env`. +- Produces: `WandererApp.Env.discord_bot_token/0 :: String.t() | nil`, `WandererApp.Env.discord_guild_id/0 :: pos_integer() | nil`, `WandererApp.Env.discord_voice_mentions_enabled?/0 :: boolean()`. Tasks 3 and 4 call all three. + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/env_discord_voice_test.exs`: + +```elixir +defmodule WandererApp.EnvDiscordVoiceTest do + # async: false โ€” mutates the :external_events application env that other + # test files also override. + use ExUnit.Case, async: false + + alias WandererApp.Env + + setup do + original = Application.get_env(:wanderer_app, :external_events, []) + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + %{original: original} + end + + defp put_voice_config(original, token, guild_id) do + Application.put_env( + :wanderer_app, + :external_events, + original + |> Keyword.put(:discord_bot_token, token) + |> Keyword.put(:discord_guild_id, guild_id) + ) + end + + test "disabled when neither var is set", %{original: original} do + put_voice_config(original, nil, nil) + refute Env.discord_voice_mentions_enabled?() + assert Env.discord_bot_token() == nil + assert Env.discord_guild_id() == nil + end + + test "enabled when both are set and guild id is a positive integer string", + %{original: original} do + put_voice_config(original, "token-abc", "123456789") + assert Env.discord_voice_mentions_enabled?() + assert Env.discord_bot_token() == "token-abc" + assert Env.discord_guild_id() == 123_456_789 + end + + test "disabled when only the token is set", %{original: original} do + put_voice_config(original, "token-abc", nil) + refute Env.discord_voice_mentions_enabled?() + end + + test "disabled when only the guild id is set", %{original: original} do + put_voice_config(original, nil, "123456789") + refute Env.discord_voice_mentions_enabled?() + end + + test "malformed guild id disables the feature", %{original: original} do + for bad <- ["not-a-number", "12abc", "-5", "0", ""] do + put_voice_config(original, "token-abc", bad) + assert Env.discord_guild_id() == nil, "expected #{inspect(bad)} to parse as nil" + refute Env.discord_voice_mentions_enabled?() + end + end + + test "integer guild id passes through", %{original: original} do + put_voice_config(original, "token-abc", 42) + assert Env.discord_guild_id() == 42 + end +end +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/env_discord_voice_test.exs` +Expected: FAIL โ€” `Env.discord_bot_token/0 is undefined or private` + +- [ ] **Step 3: Add the Env functions** + +In `lib/wanderer_app/env.ex`, after `discord_max_killmail_age_seconds/0` (~line 127), add: + +```elixir +@doc """ +Bot token for the voice-mention gateway connection. `nil` when unset โ€” +voice mentions on system-channel kill notifications are then disabled. +""" +def discord_bot_token() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_bot_token) +end + +@doc """ +Guild whose voice channels feed kill-notification mentions, as a positive +integer. `nil` when unset or malformed โ€” a malformed id disables the +feature; `VoiceGateway` warns once at boot rather than per kill. +""" +def discord_guild_id() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_guild_id) + |> parse_guild_id() +end + +defp parse_guild_id(nil), do: nil +defp parse_guild_id(id) when is_integer(id) and id > 0, do: id +defp parse_guild_id(id) when is_integer(id), do: nil + +defp parse_guild_id(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} when parsed > 0 -> parsed + _ -> nil + end +end + +defp parse_guild_id(_), do: nil + +@doc """ +Voice-participant mentions are on iff both the bot token and a valid guild +id are configured. Presence of config IS the feature flag (spec decision: +env vars only, no DB toggle). +""" +def discord_voice_mentions_enabled?() do + discord_bot_token() != nil and discord_guild_id() != nil +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/env_discord_voice_test.exs` +Expected: PASS (6 tests) + +- [ ] **Step 5: Add the Nostrum dependency and release entry** + +In `mix.exs` `deps`, add (alphabetical placement among existing deps): + +```elixir +{:nostrum, "~> 0.10", runtime: false}, +``` + +In the `releases` block, extend `applications`: + +```elixir +applications: [ + wanderer_app: :permanent, + # runtime: false keeps Nostrum out of the release unless listed; :load + # ships the code without auto-starting it โ€” VoiceGateway starts it only + # when voice mentions are configured. + nostrum: :load +], +``` + +Run: `mix deps.get && mix compile` +Expected: compiles clean; nostrum fetched. + +- [ ] **Step 6: Add runtime config** + +In `config/runtime.exs`, inside the existing `config :wanderer_app, :external_events` keyword list (~line 496), add two entries (2-arity `get_var_from_path_or_env` defaults to `nil` โ€” verify against its definition in this file and use the explicit `nil` default 3-arity form if not): + +```elixir +discord_bot_token: config_dir |> get_var_from_path_or_env("DISCORD_BOT_TOKEN"), +discord_guild_id: config_dir |> get_var_from_path_or_env("DISCORD_GUILD_ID"), +``` + +Immediately after that config block, add the Nostrum block (mirrors wanderer-notifier's `runtime.exs:127`; do NOT copy its `cache_guilds:`/`caches: []` keys โ€” they are not recognized Nostrum options, and the default ETS `GuildCache` is required): + +```elixir +# Nostrum powers voice-participant mentions on Discord kill notifications. +# Configured only when a bot token exists; VoiceGateway decides at boot +# whether to actually start it. Never configured in test โ€” the suite must +# stay hermetic. +if config_env() != :test do + discord_bot_token = config_dir |> get_var_from_path_or_env("DISCORD_BOT_TOKEN") + + if discord_bot_token do + config :nostrum, + token: discord_bot_token, + gateway_intents: [:guilds, :guild_voice_states], + ffmpeg: false + end +end +``` + +In `.env.example`, add alongside other Discord/optional settings: + +```bash +# Voice-participant mentions on Discord kill notifications (optional). +# Both must be set; the bot must be invited to the guild (no permissions +# needed beyond guild visibility). +# DISCORD_BOT_TOKEN= +# DISCORD_GUILD_ID= +``` + +- [ ] **Step 7: Verify compile and full env test** + +Run: `mix compile --warnings-as-errors && mix test test/unit/env_discord_voice_test.exs` +Expected: PASS + +- [ ] **Step 8: Format and commit** + +```bash +mix format +git add mix.exs mix.lock config/runtime.exs lib/wanderer_app/env.ex .env.example test/unit/env_discord_voice_test.exs +git commit -m "feat(discord): config surface and nostrum dep for voice mentions" +``` + +--- + +### Task 2: VoiceParticipants module (pure logic + cache seam) + +**Files:** +- Create: `lib/wanderer_app/external_events/discord/voice_participants.ex` +- Test: `test/unit/external_events/discord/voice_participants_test.exs` (create) + +**Interfaces:** +- Consumes: `WandererApp.Env.discord_guild_id/0` (Task 1); `Nostrum.Cache.GuildCache.get!/1` (dep from Task 1; compile-time only reference via capture). +- Produces (all called by Task 4): + - `get_active_voice_mentions/0 :: [String.t()]` โ€” reads config + cache, rescues everything to `[]`. + - `mentions_from_guild/1 :: (map() -> [String.t()])` โ€” pure. + - `mention_prefix/1,2 :: ([String.t()], pos_integer() -> {String.t() | nil, non_neg_integer()})` โ€” budget-capped prefix + included count. + - `prepend_to_messages/2 :: ([map()], String.t() | nil -> [map()])` โ€” pure message munging. +- Test seam: `Application.get_env(:wanderer_app, :discord_voice_guild_fetcher)` โ€” a 1-arity fun replacing the `GuildCache` read; used by Task 4's tests. + +- [ ] **Step 1: Write the failing tests** + +Create `test/unit/external_events/discord/voice_participants_test.exs`: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.VoiceParticipantsTest do + use ExUnit.Case, async: true + + alias WandererApp.ExternalEvents.Discord.VoiceParticipants + + # Shapes mirror Nostrum structs: atom keys, channels as an id-keyed map. + # Channel types: 2 = GUILD_VOICE, 13 = GUILD_STAGE_VOICE, 0 = text. + defp guild(overrides \\ %{}) do + Map.merge( + %{ + id: 999, + afk_channel_id: 30, + channels: %{ + 10 => %{id: 10, type: 2}, + 20 => %{id: 20, type: 0}, + 30 => %{id: 30, type: 2}, + 40 => %{id: 40, type: 13} + }, + voice_states: [ + %{user_id: 111, channel_id: 10}, + %{user_id: 222, channel_id: 10} + ] + }, + overrides + ) + end + + describe "mentions_from_guild/1" do + test "mentions users in voice channels" do + assert VoiceParticipants.mentions_from_guild(guild()) == ["<@111>", "<@222>"] + end + + test "excludes users in the AFK channel" do + g = + guild(%{voice_states: [%{user_id: 111, channel_id: 10}, %{user_id: 333, channel_id: 30}]}) + + assert VoiceParticipants.mentions_from_guild(g) == ["<@111>"] + end + + test "excludes users whose state points at a non-voice channel" do + g = guild(%{voice_states: [%{user_id: 111, channel_id: 20}]}) + assert VoiceParticipants.mentions_from_guild(g) == [] + end + + test "includes stage channels (type 13)" do + g = guild(%{voice_states: [%{user_id: 444, channel_id: 40}]}) + assert VoiceParticipants.mentions_from_guild(g) == ["<@444>"] + end + + test "dedups user ids" do + g = + guild(%{voice_states: [%{user_id: 111, channel_id: 10}, %{user_id: 111, channel_id: 40}]}) + + assert VoiceParticipants.mentions_from_guild(g) == ["<@111>"] + end + + test "nil voice_states yields no mentions" do + assert VoiceParticipants.mentions_from_guild(guild(%{voice_states: nil})) == [] + end + + test "nil channels yields no mentions" do + assert VoiceParticipants.mentions_from_guild(guild(%{channels: nil})) == [] + end + end + + describe "mention_prefix/2" do + test "empty list produces nil prefix and zero count" do + assert VoiceParticipants.mention_prefix([]) == {nil, 0} + end + + test "joins mentions with spaces and counts them" do + assert VoiceParticipants.mention_prefix(["<@1>", "<@2>"]) == {"<@1> <@2>", 2} + end + + test "drops mentions past the budget at a mention boundary" do + # "<@1000000001>" is 13 chars; with separators, 3 fit in 41 but not 4. + mentions = Enum.map(1_000_000_001..1_000_000_004, &"<@#{&1}>") + {prefix, count} = VoiceParticipants.mention_prefix(mentions, 41) + assert count == 3 + assert prefix == "<@1000000001> <@1000000002> <@1000000003>" + assert String.length(prefix) <= 41 + end + + test "a single mention larger than the budget yields nil" do + assert VoiceParticipants.mention_prefix(["<@12345>"], 3) == {nil, 0} + end + + test "default budget truncates below Discord's 2,000-char content limit" do + # 150 mentions x 14 chars (incl. separator) ~ 2,100 chars: must + # truncate at the 1,800 default, not pass through. + mentions = Enum.map(1_000_000_001..1_000_000_150, &"<@#{&1}>") + {prefix, count} = VoiceParticipants.mention_prefix(mentions) + assert count < 150 + assert String.length(prefix) <= 1_800 + end + end + + describe "prepend_to_messages/2" do + test "nil prefix leaves messages untouched" do + messages = [%{"embeds" => [%{"title" => "kill"}]}] + assert VoiceParticipants.prepend_to_messages(messages, nil) == messages + end + + test "sets content on the first message only" do + messages = [%{"embeds" => [1]}, %{"embeds" => [2]}] + + assert VoiceParticipants.prepend_to_messages(messages, "<@1>") == [ + %{"embeds" => [1], "content" => "<@1>"}, + %{"embeds" => [2]} + ] + end + + test "prepends before existing content with a space" do + messages = [%{"embeds" => [1], "content" => "hello"}] + + assert VoiceParticipants.prepend_to_messages(messages, "<@1>") == [ + %{"embeds" => [1], "content" => "<@1> hello"} + ] + end + + test "empty message list passes through" do + assert VoiceParticipants.prepend_to_messages([], "<@1>") == [] + end + end + + describe "get_active_voice_mentions/0" do + test "returns [] when the feature is unconfigured" do + # Test env has no discord_guild_id, so this exercises the nil branch. + assert VoiceParticipants.get_active_voice_mentions() == [] + end + end +end +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mix test test/unit/external_events/discord/voice_participants_test.exs` +Expected: FAIL โ€” module `VoiceParticipants` is not available + +- [ ] **Step 3: Implement the module** + +Create `lib/wanderer_app/external_events/discord/voice_participants.ex`: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.VoiceParticipants do + @moduledoc """ + Mentions for everyone active in the configured guild's voice channels, + prepended to system-channel kill notifications. + + Voice states come from Nostrum's ETS `GuildCache`, populated passively by + the gateway connection `VoiceGateway` starts. The cache read is + microseconds and never touches the network, so it cannot slow dispatch. + + Every failure path โ€” feature unconfigured, gateway never started, guild + not cached yet โ€” returns `[]`: voice tagging must never cost a kill + notification. During a gateway reconnect the cache stays readable but + stale; pinging a recently-departed user is an accepted tradeoff (see the + design spec's error-handling section). + + Ported from wanderer-notifier's + `WandererNotifier.Infrastructure.Adapters.Discord.VoiceParticipants`. + """ + + require Logger + + # Discord channel types: 2 = GUILD_VOICE, 13 = GUILD_STAGE_VOICE + @voice_channel_types [2, 13] + + # Discord rejects `content` over 2,000 characters, and the worker treats a + # 400 as a permanent event failure feeding the auto-disable counter + # (`Worker`, `handle_post_result/2`). 1,800 leaves headroom for any + # existing content on the chunk. A partially-tagged ping beats a rejected + # notification. + @mention_budget 1_800 + + @doc """ + Mentions for the configured guild, `[]` unless the feature is configured + and the guild is cached. + """ + @spec get_active_voice_mentions() :: [String.t()] + def get_active_voice_mentions do + case WandererApp.Env.discord_guild_id() do + nil -> + [] + + guild_id -> + guild_id |> fetch_guild() |> mentions_from_guild() + end + rescue + error -> + Logger.debug(fn -> + "[VoiceParticipants] lookup failed: #{Exception.message(error)}" + end) + + [] + end + + # Seam: tests inject a fixture-returning fun via app env; production falls + # through to Nostrum's cache. A config seam (not a parameter) keeps the + # dispatcher call site zero-arity. + defp fetch_guild(guild_id) do + fetcher = + Application.get_env( + :wanderer_app, + :discord_voice_guild_fetcher, + &Nostrum.Cache.GuildCache.get!/1 + ) + + fetcher.(guild_id) + end + + @doc """ + Pure core: mention list from a guild's voice states. Public so tests can + feed fixture guilds without Nostrum running. + """ + @spec mentions_from_guild(map()) :: [String.t()] + def mentions_from_guild(guild) do + afk_channel_id = Map.get(guild, :afk_channel_id) + voice_channel_ids = voice_channel_ids(guild, afk_channel_id) + voice_states = Map.get(guild, :voice_states) || [] + + mentions = + voice_states + |> Enum.filter(&(Map.get(&1, :channel_id) in voice_channel_ids)) + |> Enum.map(&"<@#{Map.get(&1, :user_id)}>") + |> Enum.uniq() + + if mentions == [] and voice_states != [] do + Logger.debug(fn -> + "[VoiceParticipants] #{length(voice_states)} voice state(s) present " <> + "but none in a taggable channel (afk_channel_id=#{inspect(afk_channel_id)})" + end) + end + + mentions + end + + # Voice/stage channels minus the AFK channel. Filtering states against + # this set covers both "in the AFK channel" and "in a non-voice channel". + defp voice_channel_ids(guild, afk_channel_id) do + (Map.get(guild, :channels) || %{}) + |> Map.values() + |> Enum.filter(&(Map.get(&1, :type) in @voice_channel_types)) + |> Enum.map(& &1.id) + |> Enum.reject(&(&1 == afk_channel_id)) + end + + @doc """ + Joins mentions into a content prefix within `budget` characters, appending + whole mentions until the next would overflow and silently dropping the + rest. Returns `{prefix_or_nil, included_count}`. + """ + @spec mention_prefix([String.t()], pos_integer()) :: + {String.t() | nil, non_neg_integer()} + def mention_prefix(mentions, budget \\ @mention_budget) + + def mention_prefix([], _budget), do: {nil, 0} + + def mention_prefix(mentions, budget) do + {included, _size} = + Enum.reduce_while(mentions, {[], 0}, fn mention, {acc, size} -> + separator = if acc == [], do: 0, else: 1 + addition = String.length(mention) + separator + + if size + addition > budget do + {:halt, {acc, size}} + else + {:cont, {[mention | acc], size + addition}} + end + end) + + case included do + [] -> {nil, 0} + list -> {list |> Enum.reverse() |> Enum.join(" "), length(list)} + end + end + + @doc """ + Prepends `prefix` to the first message's `"content"`; embeds and all other + chunks untouched. `nil` prefix is the no-op path โ€” no empty content key, + no stray whitespace. + """ + @spec prepend_to_messages([map()], String.t() | nil) :: [map()] + def prepend_to_messages(messages, nil), do: messages + def prepend_to_messages([], _prefix), do: [] + + def prepend_to_messages([first | rest], prefix) do + content = + case Map.get(first, "content") do + nil -> prefix + existing -> prefix <> " " <> existing + end + + [Map.put(first, "content", content) | rest] + end +end +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mix test test/unit/external_events/discord/voice_participants_test.exs` +Expected: PASS (all tests) + +- [ ] **Step 5: Format and commit** + +```bash +mix format +git add lib/wanderer_app/external_events/discord/voice_participants.ex test/unit/external_events/discord/voice_participants_test.exs +git commit -m "feat(discord): voice participants module with mention budget" +``` + +--- + +### Task 3: VoiceGateway boot module + supervision entry + +**Files:** +- Create: `lib/wanderer_app/external_events/discord/voice_gateway.ex` +- Modify: `lib/wanderer_app/application.ex` (`maybe_start_external_events_services/0`, webhook_services list at ~line 269) +- Test: `test/unit/external_events/discord/voice_gateway_test.exs` (create) + +**Interfaces:** +- Consumes: `WandererApp.Env.discord_voice_mentions_enabled?/0`, `discord_bot_token/0`, `discord_guild_id/0` (Task 1). +- Produces: `VoiceGateway.start_link/1` returning `:ignore` always โ€” a boot-time side-effect module, never a running process. Supervision order does not matter for correctness (mentions degrade to `[]` until the cache warms), but it is placed before `WorkerSupervisor` to start the gateway as early as possible. + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/external_events/discord/voice_gateway_test.exs`: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.VoiceGatewayTest do + # async: false โ€” mutates :external_events application env. + use ExUnit.Case, async: false + + import ExUnit.CaptureLog + + alias WandererApp.ExternalEvents.Discord.VoiceGateway + + setup do + original = Application.get_env(:wanderer_app, :external_events, []) + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + %{original: original} + end + + defp put_voice_config(original, token, guild_id) do + Application.put_env( + :wanderer_app, + :external_events, + original + |> Keyword.put(:discord_bot_token, token) + |> Keyword.put(:discord_guild_id, guild_id) + ) + end + + test "returns :ignore when the feature is unconfigured", %{original: original} do + put_voice_config(original, nil, nil) + assert VoiceGateway.start_link([]) == :ignore + end + + test "warns once when config is partial (token without valid guild id)", + %{original: original} do + put_voice_config(original, "token-abc", "not-a-number") + + log = + capture_log(fn -> + assert VoiceGateway.start_link([]) == :ignore + end) + + assert log =~ "DISCORD_GUILD_ID" + end + + test "stays silent when nothing at all is configured", %{original: original} do + put_voice_config(original, nil, nil) + + log = + capture_log(fn -> + assert VoiceGateway.start_link([]) == :ignore + end) + + refute log =~ "DISCORD" + end + + test "a failing gateway start logs an error and still returns :ignore", + %{original: original} do + put_voice_config(original, "token-abc", "123456789") + + Application.put_env(:wanderer_app, :discord_gateway_starter, fn :nostrum -> + {:error, :boom} + end) + + on_exit(fn -> Application.delete_env(:wanderer_app, :discord_gateway_starter) end) + + log = + capture_log(fn -> + assert VoiceGateway.start_link([]) == :ignore + end) + + assert log =~ "failed to start" + end + + test "a successful gateway start logs the enabled guild", %{original: original} do + put_voice_config(original, "token-abc", "123456789") + + Application.put_env(:wanderer_app, :discord_gateway_starter, fn :nostrum -> + {:ok, [:nostrum]} + end) + + on_exit(fn -> Application.delete_env(:wanderer_app, :discord_gateway_starter) end) + + log = + capture_log(fn -> + assert VoiceGateway.start_link([]) == :ignore + end) + + assert log =~ "voice mentions enabled for guild 123456789" + end +end +``` + +Note: only the *real* gateway connection (live token, Discord reachable) stays untestable here โ€” it is covered by the manual verification checklist in Task 5. The fail-open contract itself (error tuple โ†’ logged, `:ignore` returned, tree unharmed) is exercised through the `:discord_gateway_starter` seam, the same app-env seam pattern Task 2 uses for the guild fetch. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mix test test/unit/external_events/discord/voice_gateway_test.exs` +Expected: FAIL โ€” module `VoiceGateway` is not available + +- [ ] **Step 3: Implement the module** + +Create `lib/wanderer_app/external_events/discord/voice_gateway.ex`: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.VoiceGateway do + @moduledoc """ + Boot-time starter for the Nostrum gateway connection behind voice-mention + kill notifications. + + Not a running process: `start_link/1` attempts the start and returns + `:ignore`, so a failing gateway can never take the supervision tree with + it โ€” the invariant is that voice tagging degrades, kill delivery does + not. Nostrum's own application supervises the connection from then on + (reconnect/resume included). + + Startup outcomes are the one-time signals an operator needs: `info` on + success, `error` on failure, one `warning` for partial configuration. + After boot, per-lookup health is visible via the `mention_count` + telemetry measurement and `VoiceParticipants` debug logs. + """ + + require Logger + + alias WandererApp.Env + + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + # Nothing to restart: start_link always returns :ignore. + restart: :temporary + } + end + + def start_link(_opts) do + cond do + Env.discord_voice_mentions_enabled?() -> + start_gateway() + + Env.discord_bot_token() != nil or partial_guild_config?() -> + Logger.warning( + "[VoiceGateway] voice mentions disabled: set both DISCORD_BOT_TOKEN " <> + "and a valid positive-integer DISCORD_GUILD_ID" + ) + + true -> + :ok + end + + :ignore + end + + defp start_gateway do + # Seam: tests inject a starter fun via app env to exercise the fail-open + # contract without a live token; production starts Nostrum for real. + starter = + Application.get_env( + :wanderer_app, + :discord_gateway_starter, + &Application.ensure_all_started/1 + ) + + case starter.(:nostrum) do + {:ok, _apps} -> + Logger.info( + "[VoiceGateway] Discord gateway started; voice mentions enabled " <> + "for guild #{Env.discord_guild_id()}" + ) + + {:error, reason} -> + Logger.error( + "[VoiceGateway] Discord gateway failed to start: #{inspect(reason)} โ€” " <> + "kill notifications continue without voice mentions" + ) + end + end + + # A guild id that was set but failed to parse: Env returns nil for both + # "unset" and "malformed", so re-read the raw value to tell them apart. + defp partial_guild_config? do + raw = + Application.get_env(:wanderer_app, :external_events, []) + |> Keyword.get(:discord_guild_id) + + raw != nil and Env.discord_guild_id() == nil + end +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mix test test/unit/external_events/discord/voice_gateway_test.exs` +Expected: PASS (5 tests) + +- [ ] **Step 5: Add to the supervision tree** + +In `lib/wanderer_app/application.ex`, `maybe_start_external_events_services/0`, extend the `webhook_services` list (~line 269) โ€” VoiceGateway first, so the gateway starts warming before the first delivery: + +```elixir +[ + WandererApp.ExternalEvents.WebhookDispatcher, + # Boot-side-effect only (returns :ignore): starts the Nostrum gateway + # when voice mentions are configured. Before the worker tree so the + # voice cache starts warming as early as possible. + WandererApp.ExternalEvents.Discord.VoiceGateway, + # Supervisor before the dispatcher that routes work into it, so the + # first event does not find the worker tree missing. + WandererApp.ExternalEvents.Discord.WorkerSupervisor, + WandererApp.ExternalEvents.DiscordDispatcher +] +``` + +- [ ] **Step 6: Verify compile and boot** + +Run: `mix compile --warnings-as-errors && mix test test/unit/external_events/discord/` +Expected: compiles clean, all discord unit tests pass. (`maybe_start_external_events_services` returns `[]` in test env, so the tree change is exercised only at dev/prod boot; the compile check plus the `:ignore` contract cover it.) + +- [ ] **Step 7: Format and commit** + +```bash +mix format +git add lib/wanderer_app/external_events/discord/voice_gateway.ex lib/wanderer_app/application.ex test/unit/external_events/discord/voice_gateway_test.exs +git commit -m "feat(discord): conditional nostrum gateway startup for voice mentions" +``` + +--- + +### Task 4: Dispatcher injection + telemetry + +**Files:** +- Modify: `lib/wanderer_app/external_events/discord_dispatcher.ex` (`deliver_to/5` ~line 667, `handle_delivery_result/4` ~line 690, alias block near top) +- Test: `test/unit/external_events/discord_dispatcher_test.exs` (extend) + +**Interfaces:** +- Consumes: `VoiceParticipants.get_active_voice_mentions/0`, `mention_prefix/1` โ†’ `{String.t() | nil, non_neg_integer()}`, `prepend_to_messages/2` (Task 2); `Env.discord_voice_mentions_enabled?/0` (Task 1); test seam `:discord_voice_guild_fetcher` (Task 2). +- Produces: `[:wanderer_app, :discord_dispatcher, :dispatched]` telemetry gains a `mention_count` measurement on `:system` dispatches when the feature is enabled (absent otherwise). + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/external_events/discord_dispatcher_test.exs`, add a fixture + helper near the other private helpers (e.g. after `disable_gate/0` ~line 204): + +```elixir +# Guild fixture for voice-mention tests: users 111/222 in a voice channel, +# 333 in the AFK channel, channel 20 is text. Shapes mirror Nostrum structs. +@voice_guild %{ + id: 999, + afk_channel_id: 30, + channels: %{ + 10 => %{id: 10, type: 2}, + 20 => %{id: 20, type: 0}, + 30 => %{id: 30, type: 2} + }, + voice_states: [ + %{user_id: 111, channel_id: 10}, + %{user_id: 222, channel_id: 10}, + %{user_id: 333, channel_id: 30} + ] +} + +defp enable_voice_mentions(fetcher \\ nil) do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + original + |> Keyword.put(:discord_bot_token, "test-token") + |> Keyword.put(:discord_guild_id, "999") + ) + + Application.put_env( + :wanderer_app, + :discord_voice_guild_fetcher, + fetcher || fn 999 -> @voice_guild end + ) + + on_exit(fn -> + Application.put_env(:wanderer_app, :external_events, original) + Application.delete_env(:wanderer_app, :discord_voice_guild_fetcher) + end) +end +``` + +Then add the tests (alongside the other delivery tests): + +```elixir +describe "voice mentions" do + test "system-channel kills carry voice mentions in content", %{map: map, system: w} do + enable_voice_mentions() + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + assert [{@system_url, body}] = wait_for_requests(1) + assert body["content"] == "<@111> <@222>" + refute body["content"] =~ "<@333>", "AFK-channel user must not be pinged" + end + + test "character-channel kills carry no mentions", %{map: map, notification: n} do + enable_voice_mentions() + character_webhook(n) + track(map.id, [8000]) + + event = + kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system, victim_char_id: 8000})) + + DiscordDispatcher.dispatch_event(map.id, event) + + assert [{@character_url, body}] = wait_for_requests(1) + refute Map.has_key?(body, "content") + end + + test "feature disabled leaves messages byte-identical", %{map: map, system: w} do + # No enable_voice_mentions(): test env has no token/guild id. + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + assert [{@system_url, body}] = wait_for_requests(1) + refute Map.has_key?(body, "content") + end + + test "a raising guild fetch still delivers the kill, without mentions", + %{map: map, system: w} do + enable_voice_mentions(fn _guild_id -> raise "cache boom" end) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + assert [{@system_url, body}] = wait_for_requests(1) + refute Map.has_key?(body, "content") + end + + test "dispatch telemetry carries mention_count when enabled", %{map: map, system: w} do + enable_voice_mentions() + + ref = make_ref() + parent = self() + + :telemetry.attach( + "voice-mention-count-#{inspect(ref)}", + [:wanderer_app, :discord_dispatcher, :dispatched], + fn _event, measurements, metadata, _config -> + send(parent, {:dispatched, ref, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach("voice-mention-count-#{inspect(ref)}") end) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + wait_for_requests(1) + + assert_receive {:dispatched, ^ref, measurements, %{role: :system}}, 2_000 + assert measurements.mention_count == 2 + end +end +``` + +Also add a multi-chunk test asserting only the first chunk is touched: + +```elixir +test "multi-chunk event: mentions on the first chunk only, overflow line intact", + %{map: map, system: w} do + enable_voice_mentions() + + # 31 kills: 30 rendered, 1 overflow. NEVER hard-code the request count โ€” + # chunking depends on embed sizes, so derive it from the formatter, the + # same way "does not burn kills dropped by the formatter's per-event cap" + # (~line 522) does. + kills = Enum.map(1..31, fn i -> killmail(20_000 + i) end) + expected_count = length(EmbedFormatter.format_batch(entries(kills), "J115405")) + assert expected_count > 1, "fixture must produce a multi-chunk event" + + # Mirror the multi-kill event construction used by the existing batch tests + # in this file (~line 522) โ€” that construction is authoritative, not this + # comment. + event = kill_event(%{"kills" => kills}) + + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + requests = wait_for_requests(expected_count) + bodies = Enum.map(requests, fn {_url, body} -> body end) + + assert hd(bodies)["content"] == "<@111> <@222>" + + assert List.last(bodies)["content"] == "โ€ฆand 1 more kills not shown.", + "overflow line must not be disturbed by mention injection" + + for body <- bodies |> tl() |> Enum.drop(-1) do + refute Map.has_key?(body, "content") + end +end + +test "enabled but nobody in voice: no content, telemetry mention_count 0", + %{map: map, system: w} do + enable_voice_mentions(fn 999 -> + %{id: 999, afk_channel_id: nil, channels: %{}, voice_states: []} + end) + + ref = make_ref() + parent = self() + + :telemetry.attach( + "voice-empty-#{inspect(ref)}", + [:wanderer_app, :discord_dispatcher, :dispatched], + fn _event, measurements, metadata, _config -> + send(parent, {:dispatched, ref, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach("voice-empty-#{inspect(ref)}") end) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + assert [{@system_url, body}] = wait_for_requests(1) + refute Map.has_key?(body, "content") + + assert_receive {:dispatched, ^ref, measurements, %{role: :system}}, 2_000 + assert measurements.mention_count == 0 +end + +test "disabled: dispatch telemetry carries no mention_count key", %{map: map, system: w} do + # No enable_voice_mentions(): absent measurement is the "feature off" + # signal, distinct from mention_count 0 ("enabled but nobody taggable"). + ref = make_ref() + parent = self() + + :telemetry.attach( + "voice-absent-#{inspect(ref)}", + [:wanderer_app, :discord_dispatcher, :dispatched], + fn _event, measurements, metadata, _config -> + send(parent, {:dispatched, ref, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach("voice-absent-#{inspect(ref)}") end) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + wait_for_requests(1) + + assert_receive {:dispatched, ^ref, measurements, %{role: :system}}, 2_000 + refute Map.has_key?(measurements, :mention_count) +end +``` + +**Check the existing batch tests first** (e.g. "does not burn kills dropped by the formatter's per-event cap" ~line 522) for how a multi-kill event payload is actually constructed, and mirror that construction exactly โ€” the `%{"kills" => ...}` shape above must be corrected to whatever the existing test uses. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mix test test/unit/external_events/discord_dispatcher_test.exs` +Expected: the new "voice mentions" tests FAIL (no content injected, no `mention_count`); every pre-existing test still PASSES. + +- [ ] **Step 3: Implement the injection** + +In `lib/wanderer_app/external_events/discord_dispatcher.ex`: + +1. Add `VoiceParticipants` to the existing `alias WandererApp.ExternalEvents.Discord.{...}` block. +2. Replace `deliver_to/5`'s delivery pipeline (~line 684): + +```elixir +{prefix, mention_count} = voice_mention_prefix(role) + +entries +|> EmbedFormatter.format_batch(system_name) +|> VoiceParticipants.prepend_to_messages(prefix) +|> then(&WorkerSupervisor.deliver(webhook.id, &1)) +|> handle_delivery_result(map_id, role, marked, mention_count) +``` + +3. Add the private helper below `deliver_to/5`: + +```elixir +# Voice mentions go to the system channel only (spec decision), and only +# when configured. `nil` count means "feature off" and keeps the +# measurement out of telemetry entirely, so 0 always means "enabled but +# nobody taggable" โ€” the distinction operators need. +defp voice_mention_prefix(:system) do + if Env.discord_voice_mentions_enabled?() do + VoiceParticipants.get_active_voice_mentions() + |> VoiceParticipants.mention_prefix() + else + {nil, nil} + end +end + +defp voice_mention_prefix(_role), do: {nil, nil} +``` + +(If the module refers to `WandererApp.Env` unaliased, use the full name โ€” match the file's existing style.) + +4. Extend `handle_delivery_result` โ€” all three clauses gain a trailing `mention_count` argument (ignored except in the `:ok` clause): + +```elixir +defp handle_delivery_result(:ok, map_id, role, kills, mention_count) do + measurements = + case mention_count do + nil -> %{count: length(kills)} + n -> %{count: length(kills), mention_count: n} + end + + :telemetry.execute( + [:wanderer_app, :discord_dispatcher, :dispatched], + measurements, + %{map_id: map_id, role: role} + ) +end +``` + +The `{:error, :not_running}` and `{:error, reason}` clauses change only their head: append `, _mention_count`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mix test test/unit/external_events/discord_dispatcher_test.exs` +Expected: PASS โ€” all new and all pre-existing tests. + +- [ ] **Step 5: Run the wider discord suite** + +Run: `mix test test/unit/external_events/` +Expected: PASS (no regressions in worker/router/formatter tests). + +- [ ] **Step 6: Format and commit** + +```bash +mix format +git add lib/wanderer_app/external_events/discord_dispatcher.ex test/unit/external_events/discord_dispatcher_test.exs +git commit -m "feat(discord): prepend voice-participant mentions to system kill notifications" +``` + +--- + +### Task 5: Docs, full verification, manual gateway check + +**Files:** +- Modify: `.claude/references/zoo-extensions.md` โ€” **main checkout only, post-merge**: this file is untracked and `.claude/` is in `.git/info/exclude`, so it exists only in the main checkout, not in this worktree, and can never be committed. +- Test: full suite + static analysis + +**Interfaces:** +- Consumes: everything above. +- Produces: documented, verified feature. + +- [ ] **Step 1: Document the zoo extension (manual, after merge)** + +The tracked documentation of record is the spec + this plan. `.claude/references/zoo-extensions.md` is a local-only reference file that does not exist in the worktree โ€” do NOT create it here and do NOT `git add` it. After the branch merges, append the following in the **main checkout** (match the file's existing heading style): + +```markdown +## Discord Voice-Participant Mentions + +System-channel kill notifications prepend `<@user_id>` mentions for every +member active in a voice channel of the configured guild (AFK channel +excluded). Requires `DISCORD_BOT_TOKEN` + `DISCORD_GUILD_ID`; the bot must +be invited to the guild (no permissions needed beyond guild visibility). +Intents: `guilds`, `guild_voice_states` (non-privileged). + +- Modules: `ExternalEvents.Discord.VoiceGateway` (conditional Nostrum + startup), `ExternalEvents.Discord.VoiceParticipants` (mention logic, + 1,800-char content budget), injection in `DiscordDispatcher.deliver_to/5`. +- Character-channel deliveries never carry mentions. +- Telemetry: `[:wanderer_app, :discord_dispatcher, :dispatched]` gains + `mention_count` on system dispatches when enabled. +- Spec: `docs/superpowers/specs/2026-08-07-discord-voice-mentions-design.md`. +``` + +- [ ] **Step 2: Full verification** + +```bash +mix format --check-formatted +mix credo +mix compile --warnings-as-errors +mix test test/unit/external_events/ test/unit/env_discord_voice_test.exs +mix test +``` + +Expected: all pass. If `mix dialyzer` is part of the repo's routine (PLT already built), run it too; otherwise note it skipped. + +- [ ] **Step 3: Manual gateway verification (requires operator)** + +This is the one part automated tests cannot cover. With a real bot token and guild id in `.env`: + +1. `make server` โ€” expect `[VoiceGateway] Discord gateway started; voice mentions enabled for guild ` in the log. +2. Join a voice channel; trigger/await a kill in a mapped system โ†’ the system-channel message starts with your mention and pings you. +3. Move to the AFK channel; next kill โ†’ no mention of you. +4. Unset both env vars, restart โ†’ no VoiceGateway log lines, notifications unchanged from pre-feature behavior. +5. If Nostrum fails to boot without a consumer process (watch for a startup error naming consumers), add a minimal no-op consumer module and re-verify: + +```elixir +defmodule WandererApp.ExternalEvents.Discord.VoiceGateway.Consumer do + @moduledoc """ + No-op consumer: Nostrum requires at least one consumer process in some + configurations. Voice states reach the GuildCache regardless; events are + discarded here. + """ + use Nostrum.Consumer + + def handle_event(_event), do: :noop +end +``` + +Started from `VoiceGateway.start_gateway/0` after `ensure_all_started` succeeds, via `WandererApp.ExternalEvents.Discord.VoiceGateway.Consumer.start_link/0` โ€” only add this if step 5's failure actually occurs. + +- [ ] **Step 4: Confirm nothing is left uncommitted in the worktree** + +No docs commit here โ€” the zoo-extensions.md update is the post-merge manual +step above. Verify the worktree is clean: + +```bash +git status --short +``` + +Expected: empty output (all implementation commits landed in Tasks 1-4). diff --git a/docs/superpowers/plans/2026-08-08-notifications-tab-rework.md b/docs/superpowers/plans/2026-08-08-notifications-tab-rework.md new file mode 100644 index 000000000..fbedd4945 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-notifications-tab-rework.md @@ -0,0 +1,289 @@ +# Notifications Tab Rework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Map Settings โ†’ Notifications legible at a normal window height and honest about what it is showing: two peer cards instead of one deep tree, real Discord channel names instead of webhook nicknames, and mention targets picked from a guild-scoped typeahead instead of hand-typed `user:`/`role:` snowflakes. + +**Architecture:** The single 1,990-line `MapNotificationsComponent` render tree is split into two sibling cards. `ChannelInfo` grows a fourth fact (`guild_id`) and a truthful `source` quad-state; a new `Discord.Guild` module reads roles and members over the existing `HttpClient.get/2` seam with the bot token, feeding two `live_select` pickers. Mention targets stop being a CSV form field and become chip state edited by discrete events, the same shape excluded systems and focus corporations already use. + +**Tech Stack:** Elixir 1.17 / OTP 26, Phoenix LiveView, Ash Framework 3.9, `live_select`, daisyUI, Cachex, Finch. + +## Global constraints + +- **Branch base is `guarzo/zoo`.** The whole Discord stack exists only on the fork. A worktree cut from `origin/main` does not contain `map_notifications_component.ex` at all. *(This plan's own worktree was initially cut from `main` and had to be repointed.)* +- **Ash actions, never raw Ecto**; every new action needs a `define(...)` in `code_interface`. +- **Migrations are generated:** `mix ash.codegen `. Per `ash-codegen-drift-destructive-regen`, inspect the generated migration before running it โ€” codegen only knows the snapshot and will emit `add` for columns a hand-written migration already created. +- **`error_summary/1` must never `inspect` an error struct.** `Ash.Error.Invalid` carries the submitted webhook URL in `value:`, and `sensitive? true` does not redact it. +- **`HttpClient.get/2`'s bot `Authorization` stays a parameter** (`http_client.ex:18-22`). The webhook identity read is authorised by the URL alone and must never carry a bot token. +- **`Mentions.allowed_mentions/1` always emits `"parse" => []`.** Nothing in this rework touches that module. +- **`notification_attrs/1`'s absent-key-means-keep semantics are load-bearing** (`map_notifications_component.ex:681-707`). A disabled `
      ` submits nothing; treating that as `nil` wipes a saved home system. +- Run `mix format` before every commit. Verification commands run from the worktree root. + +## Evidence and constraints + +| Claim | Evidence | +|---|---| +| The route webhook box is a card inside a card | `webhook_row/1`'s container is `rounded border border-white/10 p-3` (`:1452`); the enclosing L1 card is the identical class list (`:1638`). Nested cards are an absolute ban. | +| The system channel shows the webhook's nickname, not the channel | `resolve_uncached/1` stamps `bot_channel_label(channel_id) \|\| webhook_label(webhook)` both as `source: :resolved` (`channel_info.ex:234-237`). Downstream cannot tell `#kills` from `Zoo Killfeed`. | +| The route channel stays masked forever | `describe/1` schedules `refresh_async/1` and returns immediately (`channel_info.ex:120-128`); the task holds no reference to the LiveView (`:161-172`), so the persisted label never reaches the open tab. | +| The route row claims "No kills delivered yet" | `show_status?` defaults `true` (`:1446`); the route call site (`:1953`) does not override it, and the empty-state string is hardcoded (`:1552-1554`). | +| `status_line/1` can render a dangling separator | `channel_hint/1` returns `nil` on error (`:1166-1171`); `status_line/1` interpolates it unguarded (`:1233-1234`). | +| A save result inside a collapsed body is invisible | Why `filters_expanded?/7` carries `match?(%{scope: :filters}, message)` (`:1293-1309`), documented at `:1289-1292`. Any collapse-by-default change must remove the cause, not the guard. | +| Chips do not match the rest of the app | `chip/1` is `rounded-full bg-white/10 px-3 py-1 text-sm` plus a full ghost `<.button>Remove` (`:1411-1418`). The house idiom is daisyUI `badge badge-ghost badge-sm` (`admin_maps_live.html.heex:64,77`). daisyUI is a configured plugin (`assets/tailwind.config.js:72`). | +| The dialog cannot be made to scroll | `core_components.ex`'s `modal/1` sets `overflow-visible` in four places including `!overflow-visible` on the dialog box, with no `max-height` anywhere โ€” deliberate, so `live_select` dropdowns can escape. Collapse is the only lever on height. | +| `guild_id` is already one decode away | Tier 2 already calls `GET /channels/{channel_id}` with the bot token (`channel_info.ex:262-277`) and decodes only `"name"`. The same response carries `guild_id`. | +| Storage does not need to change for mentions | `mention_targets` is `{:array, :string}` (`map_discord_webhook.ex:284-287`) validated by `Mentions.valid_target?/1`. Splitting the *form* and recombining on save keeps `Mentions`, the dispatcher, `router_test` and `worker_test` out of the diff. | +| Search Guild Members needs no privileged intent | Discord docs, Search Guild Members: `query` + `limit` only; the intent warning appears on List Guild Members, not on search. | +| Get Guild Roles' intent requirement is **unverified** | The docs page truncated before that section. Expected to need only guild membership; **confirm with a live call before relying on it** (see Adaptation points). | + +**Conflict to flag:** CLAUDE.md says all state changes broadcast to `"maps:#{map_id}"`. The channel-identity refresh is not map state โ€” it is a UI-cache warm-up with no other consumer โ€” so this plan routes it as a direct message to the requesting LiveView instead of a map-wide broadcast. See D8. + +## Decisions for review + +### D1 โ€” Two peer cards, one tab *(observable behaviour)* + +**Kill notifications** card: + +1. Heading + delivery status pill. +2. Card-level message region. +3. Destinations: *System channel* (always shown) and *Character channel* โ€” the character row moves **out** of the filters disclosure to sit as a peer of the system row, still collapsed behind `+ Add a separate channel` when absent. +4. Master switches (`enabled`, `wh_only`) + Save. +5. Disclosure: **Kill filters** โ€” excluded systems, corporation filter. Collapsed, badged. + +**Route alerts** card (peer, no longer a disclosure): + +1. Heading + on/off state. +2. Card-level message region. +3. `route_alert_banner` (unchanged). +4. Enable toggle, home system, max jumps + Save. +5. *Route alert channel* destination at top level โ€” **no bordered inner box**. +6. **Mentions**: Users picker and Roles picker at top level, with chips. +7. Collision warning. + +`webhook_row/1` loses its own border and padding; the card owns the frame. This removes the nested-card ban violation and the "why is the route channel in a sub box" question at once. + +### D2 โ€” Collapse unconditionally, badge the problem; move messages to card level *(observable behaviour)* + +Rather than keep an escape hatch for messages rendered inside a collapsed body, **remove the cause**: every save/test/remove result renders in its card's message region, which is never collapsed. `filters_expanded?/7` and `route_expanded?/3` are deleted. The only remaining disclosure ("Kill filters") starts collapsed and carries `filters_badge/2`, extended to badge problems as well as counts (`"2 systems excluded"`, `"1 corporation ยท needs attention"`). + +Consequences: + +- `panel_message` scopes reduce to `:kills` and `:route`. The `:filters` scope is dropped. +- `webhook_scope(:character)` becomes `:kills`, resolving the remapping that sent character-channel results into a panel the character row no longer lives in. + +**Dropping `:filters` is six call sites, not one.** `webhook_scope/1` is the mapping; these are the direct emitters, and every one must be retargeted to `:kills` or its error becomes invisible the moment the filters message region is deleted: + +| Line | Message | +|---|---| +| `:401` | `"Pick a system from the list."` | +| `:413` | `"Could not remove that system."` | +| `:426` | `"Pick a corporation from the list."` | +| `:435` | `"Could not remove that corporation."` | +| `:667` | `humanize_error(error)` โ€” excluded-systems save | +| `:677` | `humanize_error(error)` โ€” focus-corps save | + +Step 6 greps `put_message(socket, :filters` to zero before it is done. Step 8 adds a message-placement test per row: trigger the failure, assert the text renders in the kills card's message region. Without those, a silently-swallowed filter error is indistinguishable from a successful save. + +### D3 โ€” Mentions are chip state edited by events, not a form field *(architecture)* + +Mention targets leave `#webhook-form-route` entirely. `@mention_users` and `@mention_roles` are `[{snowflake, label_or_nil}]` assigns; `add-mention-user` / `remove-mention-role` / etc. save immediately through `MapDiscordWebhook.update`, recombining both lists into `["user:", "role:", ...]`. This mirrors `update_excluded/3` and `update_focus_corps/3` exactly and removes the mention field from the dirty-gate question. + +The section renders only when the route webhook exists โ€” there is nothing to attach a mention to otherwise. + +**Hydration: the id is the state, the label is decoration.** Storage holds only `"user:"` / `"role:"` strings (`map_discord_webhook.ex:280-287`), so labels have to come from somewhere on mount, and the typeahead is exactly the thing that may be unavailable. The rule that keeps this safe: + +- **The chip list is built from stored ids alone**, by splitting each target on its prefix into the matching list with `label: nil`. This happens before and independently of any Discord call, so every saved target is always visible and always removable. +- **Labels are filled in opportunistically.** `Guild.roles/1` returns the whole role list in one call, so role labels are free whenever the guild is reachable. User labels are not โ€” `members/search` is query-based, and resolving N stored ids would be N calls on tab open. Stored users are left unlabelled unless the id happens to appear in a later search result. +- **A nil label renders as the raw id** in a monospace chip (`@1234567890โ€ฆ`), carrying the same `ร—` control. It is not an error state and is not hidden. +- **Recombination writes ids, never labels.** Because save is `Enum.map(users, &elem(&1, 0))`, an unlabelled target round-trips byte-identically. This is the property that makes the silent-discard failure Codex flagged impossible: no code path can drop a target for lacking a label. + +**"Add by ID" takes a bare snowflake, not a prefixed target.** `parse_mention_targets/1` (`map_notifications_component.ex:598-616`) parses a prefixed CSV out of one shared field โ€” the wrong shape once the field is split, since each input already knows whether it is users or roles. Step 5 adds `parse_mention_id(kind, raw)`, which trims, prefixes with `"user:"` / `"role:"`, and validates the result through `Mentions.valid_target?/1` โ€” the same validator, reached without asking the user to retype a prefix the input already implies. `parse_mention_targets/1` itself stays for now only if another caller needs it; Step 5 checks and deletes it if not. + +### D4 โ€” Two new attributes on `MapDiscordWebhook` *(data model, migration)* + +```elixir +# Guild this destination's channel belongs to, resolved alongside the channel +# name by `ChannelInfo` tier 2 and cached here so the mention typeahead can +# scope its search on tab open without a round trip. Public snowflake, never a +# credential; nil whenever tier 2 could not answer, which is also exactly when +# the typeahead is unavailable. +attribute :guild_id, :string + +# Which tier produced `channel_label`: `:channel` for a real `#name` read from +# `GET /channels/{id}`, `:webhook_name` for the webhook's own nickname. +# Persisted rather than inferred from a leading "#", because a webhook may +# legitimately be named "#anything" and the UI must not claim that is a +# channel. +# +# `:atom` with `one_of` follows the existing `role` attribute; a bare `:string` +# cannot carry the constraint (Ash's string type takes only length/match). +attribute :channel_label_source, :atom do + constraints one_of: [:channel, :webhook_name] +end +``` + +`cache_channel_info`'s `accept` widens to `[:channel_id, :channel_label, :channel_label_source, :guild_id]`. + +**Which guard actually protects these fields.** `cache_channel_info`'s `webhook_url`-rejection validation (`map_discord_webhook.ex:163-177`) exists for one narrow reason: AshCloak's `SetUpEncryption` re-adds the cloaked attribute as an action *argument* regardless of `accept`, so the validation slams that argument shut. It does **not** authenticate the caller or validate the cached identity fields, and it is not what stops a crafted form submit from claiming this destination posts to `#some-innocent-channel`. That boundary is the **normal update action's restricted `accept` list** (`map_discord_webhook.ex:76-94`), which is deliberately `[:webhook_url, :enabled?, :mention_targets]` rather than `default_accept` โ€” none of the four cache fields are reachable from the settings forms at all. Widening `cache_channel_info` does not widen that boundary, because the two actions are separate. **Step 1 adds a test asserting the normal update action rejects `channel_label`, `channel_label_source`, and `guild_id`**, so the boundary is pinned rather than assumed. + +**Legacy rows.** Rows written before this change already carry `channel_id` and `channel_label` (`map_discord_webhook.ex:266-278`) with no source โ€” the column is nil for every webhook currently configured, which is all of them. No backfill can recover the truth: nothing recorded which tier produced the stored label. So `source` carries a fourth value, `:unknown`, covered in D5. There is no data migration; the column is nullable and legacy rows self-heal on first view. + +### D5 โ€” `ChannelInfo.info` gains `guild_id`; `source` becomes a quad-state *(interface)* + +```elixir +@type info :: %{ + label: String.t(), + channel_id: String.t() | nil, + guild_id: String.t() | nil, + source: :channel | :webhook_name | :unknown | :masked + } +``` + +`bot_channel_label/1` becomes `bot_channel/1`, returning `%{label: label, guild_id: guild_id}` or `nil`. `resolve_uncached/1` stamps `:channel` for a tier-2 answer and `:webhook_name` for the tier-1 nickname fallback. `persisted/1` reads `channel_label_source` back off the row instead of hardcoding `:resolved`. `ttl_for/1` keeps the long TTL for `:channel` and `:webhook_name`. + +**`:unknown` is the legacy state, and it is self-healing.** A persisted row whose `channel_label_source` is nil maps to `source: :unknown`. Three consequences, all deliberate: + +- **The UI makes no claim.** `:channel` renders `Channel: #kills`, `:webhook_name` renders `Webhook: Zoo Killfeed`, `:unknown` renders the bare stored label with neither prefix. Guessing from a leading `#` is exactly the inference D4 exists to stop. +- **`describe/1` treats `:unknown` as stale** and schedules `refresh_async/1`, the same as a cache miss, while still returning the stored label immediately. The first time anyone opens the tab the row is rewritten with a real source โ€” so `:unknown` drains from the table on its own, without a backfill migration and without a masked-until-refresh regression that would hide a label users can see today. +- **`ttl_for(:unknown)` is short** (the masked TTL), so a row that fails to resolve retries rather than pinning `:unknown` for an hour. + +`guild_id` is nil on every legacy row, which correctly routes those webhooks into D7's manual-entry fallback until a refresh fills it in. + +The settings tab renders the distinction: `Channel: #kills` versus `Webhook: Zoo Killfeed` with a one-line note that the channel name needs the bot in the guild. This is issue #3's actual fix โ€” the label was never wrong, only mislabelled. + +### D6 โ€” A new `Discord.Guild` module over the existing HTTP seam *(architecture)* + +`lib/wanderer_app/external_events/discord/guild.ex`: + +```elixir +@spec roles(String.t()) :: {:ok, [%{id: String.t(), name: String.t()}]} | {:error, term()} +@spec search_members(String.t(), String.t(), keyword()) :: + {:ok, [%{id: String.t(), name: String.t()}]} | {:error, term()} +``` + +- `GET /guilds/{guild_id}/roles` and `GET /guilds/{guild_id}/members/search?query=โ€ฆ&limit=โ€ฆ`, both on `@bot_api_base` v10 with `Authorization: Bot โ€ฆ`. +- Same `safe_get`-style `rescue`/`catch` discipline as `ChannelInfo` โ€” an unrescued raise on a keystroke path already killed this tab once (`channel_info.ex:453-458`). +- Roles are cached in `:api_cache` (a guild's role list changes rarely); member searches are **not** cached โ€” the query is the cache key and the space is unbounded. +- Deliberately **not** Nostrum. Nostrum's gateway only starts when `discord_voice_mentions_enabled?/0` is true (`voice_gateway.ex`), which would couple the typeahead to voice mentions being configured. `HttpClient.get/2` is the seam the test suite already stubs. + +**Test seam: Mox, not the shared stub.** `WandererApp.ExternalEvents.Discord.HttpStub` (`test/support/discord_http_stub.ex:1`) is the config-wired default, but its `get/2` discards headers, maps one exact URL to one fixed response, and records nothing (`:49-63`). It cannot express a query-string search, a second search returning different results, or an assertion that the bot token was sent. `Test.DiscordHttpClientMock` (`test/support/mock_definitions.ex:181-185`) is a Mox against the same behaviour and exists for precisely this โ€” "tests that want per-call expectations instead, and swaps the config for their duration". `Discord.Guild`'s unit tests use the Mox; `HttpStub` is left alone so the delivery tests keep their shared scripted queue. + +Required coverage for `Discord.Guild`: URL-encoding of a query containing a space and a `&`; the `Authorization: Bot โ€ฆ` header actually present on both calls; 401 and 403 distinctly (D7's fallback trigger); a 200 with malformed JSON; a raise and an exit inside the client; and a cache-hit assertion for `roles/1` proving the second call does not hit HTTP. + +### D7 โ€” Degrade, visibly, when the typeahead cannot work *(failure handling)* + +The typeahead needs a bot token **and** the bot in the destination's guild. Neither is guaranteed; `channel_info.ex:24` already documents that "403 here is normal". When `guild_id` is nil or a search returns 401/403: + +- The pickers are replaced by a plain "Add by ID" input per list, taking a bare snowflake and validating through `parse_mention_id/2` (D3). +- A short line states why: *"Add the bot to this guild to search names."* Not silent, not a dead picker. + +Chips already saved stay visible and removable throughout โ€” they are built from stored ids, not from the picker (D3). + +This is the single most important failure path in the rework. A picker that returns nothing looks identical to a guild with no roles. + +### D8 โ€” The refresh notifies the requesting LiveView directly *(architecture)* + +`describe/1` keeps its arity and current behaviour; a new **`describe/2`** takes an options keyword and accepts `notify: pid`. Every existing caller โ€” the render helper at `map_notifications_component.ex:1166-1170` and the unit tests at `test/unit/external_events/discord/channel_info_test.exs:261-299` โ€” compiles and behaves unchanged. Only the settings-tab render path moves to `describe/2`. + +When the background task persists a `:channel` / `:webhook_name` result it sends to that pid. `maps_live` handles the message and calls `send_update(MapNotificationsComponent, id: "map-notifications", channel_info_version: System.unique_integer())`, forcing a re-render that now hits a warm cache. + +**The message must be a three-tuple: `{:discord_channel_info, notification_id, source}`.** A two-tuple would be a live crash, not a style preference. `maps_live.ex:651-667` ends with an unguarded + +```elixir +def handle_info({ref, result}, socket) do + Process.demonitor(ref, [:flush]) +``` + +catch-all for `Task.async` replies. A `{:discord_channel_info, id}` message matches it, and `Process.demonitor/2` raises `ArgumentError` on an atom โ€” so the first async channel refresh would take down the whole map LiveView. A three-tuple cannot match that clause, and cannot match the `{_event, {:flash, type, message}}` clause above it either. Ordering the new clause before the catch-all would also work and is rejected: it makes correctness depend on source-file position, which the next person to add a handler has no reason to preserve. + +**Delivery to a gone process is already safe.** `send/2` to a dead pid is a no-op in Erlang โ€” a user closing the tab or navigating away between the request and the reply needs no guard. A *replaced* LiveView (reconnect) is a different pid that never requested the refresh and so is never sent to; it re-renders from the now-warm cache on its own mount. Neither case needs a monitor. + +`ChannelInfo` sends a **plain message**, not a `send_update` โ€” the domain module stays free of any LiveView dependency; the web layer decides what to do with the ping. + +**Step 3 tests, explicitly:** (a) `describe/2` with `notify:` delivers the three-tuple after the task persists; (b) `describe/1` still schedules a refresh and sends nothing; (c) a dead pid does not raise; (d) `maps_live` handles the three-tuple without falling through to the `{ref, result}` clause โ€” a regression test for the crash above, asserting the LiveView is alive afterwards. + +**Rejected:** a `"maps:#{map_id}"` broadcast. `ChannelInfo` has no `map_id` (it holds `notification_id`), `cache_channel_info` deliberately has no `after_transaction` hook (`map_discord_webhook.ex:143-151`), and the message has exactly one consumer. **Rejected:** polling โ€” it would re-render the tab on a timer for a value that changes once. + +### D9 โ€” Guild-scoped ids are correct by construction; manual ids are not *(security)* + +Discord renders an unknown role mention as inert text with no error. Sourcing ids from a picker scoped to *this webhook's* guild makes that failure structurally impossible โ€” which is why per-webhook `guild_id` was chosen over the installation-wide `DISCORD_GUILD_ID`, whose ids would be silently inert on any map pointed at a different guild. That is the exact silent-inert-config class #137 existed to remove. + +The D7 manual fallback cannot offer that guarantee. Its help text says so plainly rather than implying validation it cannot perform. + +### D10 โ€” Copy and styling *(routine)* + +- `chip/1` โ†’ daisyUI `badge badge-ghost badge-sm` with a compact `ร—` control replacing the full "Remove" button. +- Corporation-filter help shortened to two short sentences; the long explanation moves to `docs/ZOO-FORK.md`. +- Headings scoped to kills: "Kill filters", and the intro line states filters do not affect route alerts. +- `webhook_row/1` gains `empty_status_text`; the route row reads "No route alerts delivered yet." +- `status_line/1` omits the separator when `channel_hint/1` is nil. +- `message_class(:info)` moves off `text-green-400` so it stops colliding with the `:delivering` status green. +- `disclosure/1` gains `aria-expanded` / `aria-controls` and swaps the literal `โ–ธ` glyph for an `aria-hidden` icon. + +## Alternatives and tradeoffs + +| Decision | Chosen | Rejected, and why | +|---|---|---| +| Height | Collapse + badge | Scrolling the dialog: `modal/1` sets `overflow-visible` with no max-height on purpose, so `live_select` dropdowns can escape. Changing that belongs to `core_components`' owner and would break every dropdown in the app. | +| Guild scope | Per-webhook `guild_id` | Installation-wide `DISCORD_GUILD_ID`: documented as the *voice* guild (`env.ex:151`), and would offer ids that render inert on any map pointed elsewhere. | +| Mention storage | Unchanged `{:array, :string}` | A new `{user_ids, role_ids}` shape: would drag `Mentions`, the dispatcher, and the security-critical `allowed_mentions/1` path into the diff for no user-visible gain. | +| Discord client | `HttpClient.get/2` | Nostrum: gateway is gated on voice mentions being configured; would make the typeahead depend on an unrelated feature flag. | +| Collapsed-message safety | Move messages to card level | Keep the `%{scope: :filters}` escape hatch: preserves the bug's cause and makes the disclosure's initial state depend on transient message state. | + +## Ordered implementation steps + +- [ ] **1. Data model.** Add `guild_id` and `channel_label_source` to `MapDiscordWebhook`; widen `cache_channel_info`'s `accept`. `mix ash.codegen add_webhook_guild_identity`; **read the generated migration** before `mix ash.migrate`. No backfill โ€” legacy rows are nil by design (D4/D5). Add the boundary test: the normal update action rejects `channel_label`, `channel_label_source`, and `guild_id`. +- [ ] **2. `ChannelInfo`.** `info` type gains `guild_id`; `source` becomes `:channel | :webhook_name | :unknown | :masked`; `bot_channel_label/1` โ†’ `bot_channel/1` decoding `guild_id`; `persist/2` and `persisted/1` carry all four fields; nil `channel_label_source` โ†’ `:unknown`, which `describe` treats as stale and `ttl_for` gives the short TTL. Unit tests for each tier **plus a legacy row**: label returned immediately, refresh scheduled, no `Channel:`/`Webhook:` claim. +- [ ] **3. `describe/2` notify + re-render.** New `describe/2` with `notify:` (arity-1 untouched), three-tuple `{:discord_channel_info, notification_id, source}` from the task, `handle_info` in `maps_live`, `send_update` into the component. Tests (a)โ€“(d) from D8, including the `{ref, result}` catch-all regression. +- [ ] **4. `Discord.Guild`.** `roles/1`, `search_members/3`, caching, `rescue`/`catch`, `Test.DiscordHttpClientMock` expectations with the D6 coverage list. Verify Get Guild Roles against a live guild before wiring the UI. +- [ ] **5. Mention state.** `@mention_users`/`@mention_roles` assigns hydrated from stored ids with `label: nil`, add/remove events, id-only recombination on save, unlabelled-chip rendering, `parse_mention_id/2`, `live_select` handlers for both pickers, D7 fallback path. Check `parse_mention_targets/1` for remaining callers; delete if none. +- [ ] **6. Render split.** Two peer cards; `webhook_row/1` de-bordered with `empty_status_text`; character row promoted out of the filters disclosure; route card assembled; `route_expanded?/3` and `filters_expanded?/7` deleted; message regions moved to card level; `webhook_scope(:character)` โ†’ `:kills`; all six `put_message(socket, :filters` sites retargeted (D2 table) and grepped to zero. +- [ ] **7. Copy and chips (D10).** Mechanical; group into one commit. +- [ ] **8. Tests.** Update the two CSV mention tests to drive the pickers; add manual-fallback coverage; add channel-vs-webhook-vs-unknown label coverage; add the six filter message-placement tests; keep every preserved DOM id green. + +## Testing and verification strategy + +**DOM ids that must survive** (asserted across `test/wanderer_app_web/live/map_notifications_test.exs`, 1,501 lines): `#discord-notification-form`, `#route-alerts-form`, `#webhook-form-{system,character,route}`, `#webhook-row-{system,character,route}`, `#excluded_system_live_select_component`, `#focus_corp_live_select_component`, `#home_system_live_select_component`, `input[name='notification[home_system_id]']`, and the `#route-alerts-form fieldset[disabled]` selector. `:1385` regex-matches the raw `
      ` tag per role, so the forms must stay real `` elements with those exact ids. + +**Tests that must change** (disclosed, not incidental): `map_notifications_test.exs:1405` and `:1429` submit `mention_targets` as CSV through `#webhook-form-route`. Under D3 that field no longer exists there; both are rewritten to drive the pickers, and the CSV-validation assertion moves to the D7 manual-entry path, where `parse_mention_id/2` covers the same invalid-snowflake rejection through `Mentions.valid_target?/1`. + +**Tests added by the review pass**, each pinning a failure that would otherwise be silent: + +| Test | Guards against | +|---|---| +| Normal update rejects the four cache fields (Step 1) | D4's real boundary going unpinned | +| Legacy row: label shown, refresh scheduled, no source claim (Step 2) | `:unknown` regressing to a guess or to masked | +| `maps_live` survives `{:discord_channel_info, _, _}` (Step 3) | The `{ref, result}` catch-all crash | +| Six filter message-placement tests (Step 8) | Filter errors vanishing with the `:filters` scope | +| Unlabelled mention chip renders and removes (Step 8) | Silent discard of stored targets with no label | + +Commands: + +``` +mix format --check-formatted +mix credo +mix test test/wanderer_app_web/live/map_notifications_test.exs +mix test test/unit/external_events/ +mix test +cd assets && yarn build +``` + +Plus a manual pass at ~900px viewport height with both cards collapsed, confirming the whole tab is visible โ€” that is issue #1's acceptance criterion and no automated test covers it. + +## Adaptation points + +- **Get Guild Roles' intent requirement is unverified.** If it turns out to need a privileged intent, D6's `roles/1` becomes unavailable on instances that have not enabled it, and the Roles picker falls back to D7's manual entry permanently. The Users picker (`members/search`) is unaffected. Revisit D6 if this is confirmed. +- **`live_select` may not support two independent pickers of the same shape** inside one component without id collisions. If so, they get explicit distinct ids (`mention_user_โ€ฆ`, `mention_role_โ€ฆ`) โ€” already the plan, but verify the `send_update` targeting works for both. +- **If the generated migration conflicts** with an existing hand-written one (the known codegen-drift failure mode), reconcile the snapshot rather than editing the migration in place. +- **If de-bordering `webhook_row/1` makes the three call sites visually diverge**, the row may need a `variant` attr rather than a flat removal. +- **If `:unknown` proves visually unacceptable** โ€” an unprefixed label next to two prefixed ones reading as a rendering bug rather than as honest uncertainty โ€” the fallback is a neutral prefix (`Posting to: โ€ฆ`) rather than guessing the source. Do not infer from a leading `#`. +- **Steps 1โ€“4 are independently landable**; 5 and 6 are not. Step 5 introduces assigns that Step 6's render tree consumes, and Step 6 deletes the form field Step 5 replaces โ€” between them the mention UI is inconsistent. Land them as one commit, or land 6 first with the old CSV field still wired and swap it in 5. + +## Explicit exclusions + +- `Mentions`, `DiscordDispatcher`, `Router`, `WorkerSupervisor`, and the delivery path are untouched. +- `core_components.ex`'s `modal/1` overflow behaviour is untouched. +- No change to `mention_targets`' storage shape, validation, or `allowed_mentions/1`. +- No Nostrum/voice-mention changes; `DISCORD_GUILD_ID` keeps its current meaning. +- Kill-destination (system/character) mention targets keep today's behaviour โ€” no picker UI is added for them in this pass. +- No new authorizer or Ash policy (see `wanderer-ash-no-policies`). diff --git a/docs/superpowers/plans/2026-08-09-discord-killmail-notification-fixes.md b/docs/superpowers/plans/2026-08-09-discord-killmail-notification-fixes.md new file mode 100644 index 000000000..a4088d8a1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-discord-killmail-notification-fixes.md @@ -0,0 +1,1465 @@ +# Discord Killmail Notification Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop Discord kill notifications firing for systems removed from a map, and stop already-posted killmails being posted again after a restart. + +**Architecture:** Two independent fixes. Defect 1 adds the missing `visible` filter to the kill fan-out index and backs it with a fail-open membership guard in the Discord dispatcher. Defect 2 tightens the freshness filter for a grace window after the dedup cache loses its marks. The window is derived from a sentinel stored **in that cache**, read **once per kill batch** rather than at dispatcher start, so it tracks the cache's lifecycle rather than the dispatcher's. + +**Tech Stack:** Elixir, Ash Framework, Cachex, `:telemetry`, ExUnit. + +**Source spec:** `docs/superpowers/specs/2026-08-09-discord-killmail-notification-fixes-design.md` + +## Global Constraints + +- **Never resolve config per killmail.** `Env` accessors log a warning on a bad value; reading one inside a per-kill loop turns a single misconfigured deployment into a warning-per-kill log flood. Every config read stays once-per-batch. This is documented at `lib/wanderer_app/external_events/discord_dispatcher.ex:230-237` and `:922-928`, and both comments must survive. +- **Fail open, always.** Every new guard in the Discord path drops a killmail only on a positive finding. Any failure to read state (missing cache entry, unstarted cache, raised exception) lets the batch through. This is the existing `:unknown`-is-not-`:not_involved` posture (`lib/wanderer_app/external_events/discord/router.ex:18-28`). +- **`Cachex.get/2` raises against an unstarted cache** rather than returning an error tuple. Any new Cachex read outside the dispatcher's supervised lifetime needs a `rescue`, following `Matcher.tracked_eve_ids/1` (`lib/wanderer_app/external_events/discord/matcher.ex:53-60`). +- **At-most-once delivery is unchanged.** No task in this plan may make delivery at-least-once, add a persistence layer for dedup marks, or move the dedup mark after delivery confirmation. +- **Test config keys are restored wholesale.** Every test that mutates `:external_events` reads the whole keyword list, `Keyword.put`s onto it, and restores the original list in `on_exit` โ€” never `Application.put_env` with a fresh list. Pattern: `discord_killmail_age_test.exs:13-24`. +- Run `mix format` before every commit. The final gate checks formatting. + +--- + +## File Structure + +| File | Responsibility | Tasks | +|---|---|---| +| `lib/wanderer_app/kills/subscription/system_map_index.ex` | Fan-out index build query | 1 | +| `test/unit/kills/subscription/system_map_index_test.exs` | **New.** Index visibility regression | 1 | +| `lib/wanderer_app/external_events/discord_dispatcher.ex` | Membership guard, startup window, drop telemetry | 2, 4, 5 | +| `lib/wanderer_app/env.ex` | Two new config accessors + non-negative validator | 3 | +| `config/runtime.exs` | Env-var wiring for both new keys | 3 | +| `config/test.exs` | Disable the startup window under test | 3 | +| `.env.example`, `README.md` | Operator documentation for both env vars | 3 | +| `test/unit/external_events/discord_dispatcher_test.exs` | Guard behaviour | 2 | +| `test/unit/external_events/discord_killmail_age_test.exs` | New `Env` accessor coverage | 3 | +| `test/unit/external_events/discord_startup_window_test.exs` | **New.** Window behaviour, lifecycle, telemetry | 4, 5 | + +--- + +## Deviations from the spec + +Three, all discovered by reading the code while writing this plan. Each is deliberate; do not "correct" them back. + +1. **Telemetry event name.** The spec names `[:wanderer_app, :discord, :killmail_dropped]`. This plan uses **`[:wanderer_app, :discord_dispatcher, :killmail_dropped]`**. The `:discord` prefix in this module is used for *enrichment* events (`:notable_items`, `:corp_tickers` โ€” `discord_dispatcher.ex:521,671`), while dispatch-outcome events use `:discord_dispatcher` (`:dispatched`, `:not_delivered` โ€” `:762,795`). A drop is a dispatch outcome and shares their `%{count: n}` measurement shape and `map_id` metadata. + +2. **The sentinel needs two Cachex calls, not one.** The spec says the sentinel is written "with no TTL". `:discord_dedup_cache` is created with `default_ttl: :timer.hours(24)` (`lib/wanderer_app/application.ex:150-154`), and `Cachex.Actions.Put.execute/4` only honours an **integer** `:ttl` โ€” anything else, `nil` included, falls back to the cache default. So `Cachex.put/3` cannot write a never-expiring entry here. The fix is `Cachex.put/3` followed by `Cachex.persist/2`, which clears the expiration. Without the `persist`, the sentinel would silently expire after 24 hours of uptime and a later restart would spuriously re-arm the window. + + (Cachex is pinned at **3.6.0** through Hex โ€” `mix.lock:12`. The behaviour above is in that package's `lib/cachex/actions/put.ex`; there is no vendored copy in the repository, so it is not reachable by a path relative to this worktree. Read it from the resolved dependency tree if you want to confirm it.) + +3. **Line numbers.** The spec cites `system_map_index.ex:98` for the unfiltered query; it is actually **line 103**. The spec's `map_integration.ex` citations are likewise a few lines off. Trust this plan's line numbers, which were re-read from the working tree. + +--- + +## Revisions after independent review + +An independent review of this plan against the code found five defects. All five are folded in below; this section records what changed so a reader of an earlier draft is not misled. + +1. **The sentinel is read per batch, not in `init/1`.** The original Task 4 cached the deadline in dispatcher state at start-up. That gets lifecycle row 2 โ€” the case the sentinel design exists for โ€” exactly as wrong as the design it replaced: a dedup-cache-only crash loses every mark while the dispatcher keeps running with a stale deadline, and nothing ever re-arms. Reading the sentinel once per `:map_kill` batch fixes it and is a net simplification. It removes the `init/1` change, the state field, and the `do_dispatch/2` โ†’ `/3` arity change across three clauses. Cost: one ETS read per batch, against work that already includes a DB read and an HTTP post. + +2. **Both new config keys are wired in `config/runtime.exs`.** The original Task 3 added accessors and test config but no env-var wiring, so a release could not configure either key. Task 3 now wires, documents, and tests them alongside the sibling `WANDERER_DISCORD_MAX_KILLMAIL_AGE_SECONDS` (`config/runtime.exs:497-499`). + +3. **Drop reasons are classified per kill against both thresholds.** The original Task 5 labelled every age drop `:startup_age` whenever the window was open, so a two-hour-old kill โ€” one the pre-existing 3600-second limit would have dropped anyway โ€” was reported as new suppression. That overstates the window's impact in precisely the metric an operator would use to decide the window is too aggressive. + +4. **Two classes of test that could pass for the wrong reason are tightened.** Deadline comparisons no longer depend on millisecond resolution around an immediate re-arm, and the "delivered" assertions now require an actual HTTP request rather than only the absence of drop telemetry. + +5. **Citations corrected.** The Cachex claim is cited to the Hex package rather than a `deps/` path that does not resolve from a worktree (see Deviation 2). + +--- + +## Task 1: Filter the fan-out index by system visibility + +Removing a system from a map is a soft delete โ€” `MapSystemRepo.remove_from_map/2` sets `visible: false` (`lib/wanderer_app/repositories/map_system_repo.ex:49-58`). `SystemMapIndex` builds its `system_id -> [map_ids]` index with `get_all_by_map/1`, which has no `visible` filter, so every system a map has ever contained maps to that map permanently. + +**Files:** +- Modify: `lib/wanderer_app/kills/subscription/system_map_index.ex:103` +- Test: `test/unit/kills/subscription/system_map_index_test.exs` (create) + +**Interfaces:** +- Consumes: `WandererApp.MapSystemRepo.get_visible_by_map/1`, which already exists (`map_system_repo.ex:45-47`) and delegates to the `:read_visible_by_map` Ash action (`lib/wanderer_app/api/map_system.ex:251-254`). +- Produces: no signature change. `SystemMapIndex.get_maps_for_system/1` keeps returning `[String.t()]`. + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/kills/subscription/system_map_index_test.exs`: + +```elixir +defmodule WandererApp.Kills.Subscription.SystemMapIndexTest do + # `async: false`: the index owns a NAMED ETS table and a named GenServer, so + # two of these running concurrently would fight over both. + use WandererApp.DataCase, async: false + + alias WandererApp.Kills.Subscription.SystemMapIndex + alias WandererAppWeb.Factory + + @visible_system 31_000_005 + @removed_system 31_000_006 + + # A real removal, through the repo function the map server calls, rather than + # writing `visible: false` directly โ€” so this test fails if removal ever stops + # being a soft delete and starts destroying the row. + test "a system removed from the map is dropped from the index" do + map = Factory.insert(:map, %{}) + + Factory.insert(:map_system, %{map_id: map.id, solar_system_id: @visible_system}) + Factory.insert(:map_system, %{map_id: map.id, solar_system_id: @removed_system}) + + {:ok, _} = WandererApp.MapSystemRepo.remove_from_map(map.id, @removed_system) + + start_supervised!(SystemMapIndex) + # `init/1` sends itself `:build_index`; a system message is appended behind + # it, so this returns only once the build has run. + :sys.get_state(SystemMapIndex) + + assert SystemMapIndex.get_maps_for_system(@visible_system) == [map.id] + assert SystemMapIndex.get_maps_for_system(@removed_system) == [] + end +end +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +Run: `mix test test/unit/kills/subscription/system_map_index_test.exs` + +Expected: FAIL. The second assertion reports `[] == []` โ€” the removed system is still indexed. + +If the FIRST assertion fails instead, stop: the fixture is wrong, not the code. Check that `Factory.insert(:map_system, ...)` accepted `solar_system_id`. + +- [ ] **Step 3: Apply the one-line fix** + +In `lib/wanderer_app/kills/subscription/system_map_index.ex`, at line 103, change the repo call inside `fetch_all_map_systems/0`: + +```elixir + # Visible systems ONLY. Removal from a map is a soft delete + # (`MapSystemRepo.remove_from_map/2` sets `visible: false`), so + # `get_all_by_map/1` here indexed every system the map had ever + # contained and kills kept broadcasting for removed systems forever. + # The sibling `MapIntegration.get_tracked_system_ids/0` already uses + # this variant, and `MapSystem` carries a partial index for exactly + # this filter (`api/map_system.ex:44`). + case WandererApp.MapSystemRepo.get_visible_by_map(map.id) do +``` + +- [ ] **Step 4: Run the test and confirm it passes** + +Run: `mix test test/unit/kills/subscription/system_map_index_test.exs` + +Expected: PASS. + +- [ ] **Step 5: Run the broader kills suite for regressions** + +Run: `mix test test/unit/kills/` + +Expected: PASS. This fix changes the in-app kills widget too โ€” a system removed from a map stops showing kill activity. That is intended (see the spec's blast-radius table). If a test asserts the old behaviour, it is asserting the bug; read it carefully before changing it, and say so in the commit body. + +- [ ] **Step 6: Format and commit** + +```bash +mix format +git add lib/wanderer_app/kills/subscription/system_map_index.ex test/unit/kills/subscription/system_map_index_test.exs +git commit -m "fix(kills): index only visible systems for kill fan-out + +Removing a system from a map is a soft delete, but SystemMapIndex built +its system->maps index with get_all_by_map/1, which has no visible +filter. Every system a map had ever contained mapped to that map +permanently, so kills kept broadcasting for removed systems -- to the +in-app kills widget and to Discord. + +Affects the kills widget as well as Discord, in the same direction: a +system that was removed should not light up with kill activity." +``` + +--- + +## Task 2: Fail-open map-membership guard in the dispatcher + +Task 1 fixes persistent membership. It does not close the staleness window: `SystemMapIndex.refresh/0` runs only on the `:ok` branch of `MapEventListener.do_update_subscriptions/1` (`lib/wanderer_app/kills/map_event_listener.ex:177-183`), the retry path replaces it while the kills client is disconnected (`:220-237`), and per-map topics are not subscribed until the first `:resubscribe_to_maps`, 60 seconds after init (`:26-29`, `:111-133`). The backstop is the index's 5-minute periodic refresh (`system_map_index.ex:12,127-129`), so exposure is up to five minutes. + +The live map cache is strictly fresher: `WandererApp.Map.remove_system/2` drops the system immediately (`lib/wanderer_app/map.ex:507-521`). The guard consults it, and drops the batch **only** on a positive "this map does not have that system". + +**Files:** +- Modify: `lib/wanderer_app/external_events/discord_dispatcher.ex:223-270` +- Test: `test/unit/external_events/discord_dispatcher_test.exs` + +**Interfaces:** +- Consumes: `WandererApp.Map.get_map/1` โ†’ `{:ok, %WandererApp.Map{systems: %{integer => map()}}} | {:error, :not_found}` (`lib/wanderer_app/map.ex:59-67`). The `systems` map is keyed by `solar_system_id` (`map.ex:20`, and `add_system/2` at `:480`). +- Consumes: `extract_kills/1` already yields `{:ok, system_id, killmails}` with `system_id` an integer โ€” `MapIntegration.broadcast_kill_to_maps/1` guards `is_integer(system_id)` before broadcasting at all (`lib/wanderer_app/kills/subscription/map_integration.ex:161-162`), so no type coercion is needed here. +- Produces: private `system_on_map?/2`. Nothing outside this module consumes it. + +- [ ] **Step 1: Write the three failing tests** + +Append to `test/unit/external_events/discord_dispatcher_test.exs`, inside the main `WandererApp.ExternalEvents.DiscordDispatcherTest` module (after the existing `test "delivers a wormhole kill"`). + +Note the deliberate asymmetry in the fixtures: the third test seeds nothing, because the *existing* tests in this file seed nothing either. That is what keeps them all green. + +```elixir + # Seeds the live map cache the guard reads. `:map_cache` is a global Cachex + # table, NOT sandboxed per test, so every seed must be torn down. + defp seed_map_systems(map_id, solar_system_ids) do + systems = + Map.new(solar_system_ids, fn id -> {id, %{solar_system_id: id}} end) + + WandererApp.Map.update_map(map_id, %{systems: systems}) + on_exit(fn -> Cachex.del(:map_cache, map_id) end) + :ok + end + + test "delivers a kill for a system that is on the map", %{map: map, system: w} do + seed_map_systems(map.id, [@wh_system]) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + assert [{url, _body}] = wait_for_requests(1) + assert url == @system_url + end + + test "drops a kill for a system absent from a readable map cache", %{map: map, system: w} do + # Readable, and positively does not contain @wh_system. + seed_map_systems(map.id, [@ks_system]) + + kill = killmail(4001, %{"solar_system_id" => @wh_system}) + + event = + kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system, killmails: [kill]})) + + DiscordDispatcher.dispatch_event(map.id, event) + + refute_delivery(w.id) + + # The kill must NOT be marked: it was never attempted, so it stays eligible + # if the same kill arrives again once the map cache says otherwise. + refute marked?(map.id, 4001) + end + + # The fail-open case, and the reason this guard is safe to add at all. A map + # with no live GenServer has no `:map_cache` entry, and that is not evidence + # the system was removed. + test "delivers a kill when the map is not in the cache at all", %{map: map, system: w} do + Cachex.del(:map_cache, map.id) + + event = kill_event(Factory.build(:kill_event, %{solar_system_id: @wh_system})) + + DiscordDispatcher.dispatch_event(map.id, event) + settle(w.id) + + assert [{url, _body}] = wait_for_requests(1) + assert url == @system_url + end +``` + +- [ ] **Step 2: Run the tests and confirm the right one fails** + +Run: `mix test test/unit/external_events/discord_dispatcher_test.exs` + +Expected: the **"drops a kill for a system absent"** test FAILS (a request was delivered, and `marked?` is true). The other two PASS โ€” they describe behaviour that already holds. That is the correct starting state: two of the three are regression pins for behaviour this task must not break. + +- [ ] **Step 3: Add the guard to the `:map_kill` clause** + +In `lib/wanderer_app/external_events/discord_dispatcher.ex`, add `system_on_map?/2` next to the other private helpers (put it directly above `defp enabled_globally?` at line 802): + +```elixir + # Bounds the `SystemMapIndex` staleness window: the index refreshes on a + # 5-minute timer when the kills client is disconnected, so a system removed + # from a map keeps producing kill broadcasts until the next refresh. The live + # map cache is dropped by `WandererApp.Map.remove_system/2` immediately, so it + # is strictly fresher. + # + # FAIL-OPEN, and this is the whole reason the guard is safe to add: it returns + # false ONLY on a positive "this map is readable and does not have that + # system". A map with no live GenServer has no `:map_cache` entry, which is + # not evidence of removal. `systems` is keyed by `solar_system_id` + # (`WandererApp.Map` defstruct, and `add_system/2`). + defp system_on_map?(map_id, system_id) do + case WandererApp.Map.get_map(map_id) do + {:ok, %{systems: systems}} when is_map(systems) -> Map.has_key?(systems, system_id) + _ -> true + end + rescue + # `Cachex.get/2` RAISES against an unstarted cache rather than returning an + # error tuple, and `get_map/1` has no catch-all clause, so a non-`{:ok, _}` + # return raises CaseClauseError. Either would crash the dispatcher and lose + # the whole batch โ€” the opposite of failing open. Same contract + # `Matcher.tracked_eve_ids/1` rescues (`discord/matcher.ex:53-60`). + _ -> true + end +``` + +Then add one clause to the `with` chain in `do_dispatch/2`, immediately after `extract_kills/1` โ€” it must sit **before** the age filter and dedup, so a kill for a removed system is never marked as attempted: + +```elixir + {:ok, system_id, killmails} <- extract_kills(payload), + # Before the age filter and dedup on purpose: a kill dropped here was + # never marked attempted, so it stays eligible if the same batch + # arrives again once the map cache says the system is present. + true <- system_on_map?(map_id, system_id), +``` + +- [ ] **Step 4: Run the tests and confirm all three pass** + +Run: `mix test test/unit/external_events/discord_dispatcher_test.exs` + +Expected: PASS, all three, and every pre-existing test in the file still green. The pre-existing tests never seed `:map_cache`, so they take the fail-open branch. + +- [ ] **Step 5: Format and commit** + +```bash +mix format +git add lib/wanderer_app/external_events/discord_dispatcher.ex test/unit/external_events/discord_dispatcher_test.exs +git commit -m "fix(discord): drop kills for systems no longer on the map + +Task 1 fixes persistent index membership but not staleness: the index +refreshes only after a successful kills-client subscription update, and +otherwise on a 5-minute timer, so a removed system keeps producing kill +broadcasts for up to five minutes. + +This guard consults the live map cache, which remove_system/2 updates +immediately. It is fail-open -- it drops a batch only when the map cache +reads successfully AND positively lacks the system. An unreadable or +absent cache entry lets the batch through, because a map with no live +GenServer is not evidence that a system was removed. + +Placed before the age filter and dedup, so a dropped kill is never +marked attempted and stays eligible on a later arrival." +``` + +--- + +## Task 3: Configuration for the startup window + +Two new keys in the `:external_events` keyword list. They do **not** share a validator, and the difference is load-bearing: `validate_positive_integer/3` rejects `0` and substitutes the default (`lib/wanderer_app/env.ex:285-295`). That is right for a maximum age โ€” `0` would drop every real killmail, silently. It is wrong for the grace period, where `0` is a legitimate "no startup window", and routing it through that helper would turn "disabled" into "600 seconds plus a warning". + +**Files:** +- Modify: `lib/wanderer_app/env.ex` (add two accessors + one validator, after `discord_max_killmail_age_seconds/0` at `:120-127`) +- Modify: `config/runtime.exs:497-499` (env-var wiring, beside the sibling key) +- Modify: `config/test.exs:36` +- Modify: `.env.example:24-27`, `README.md:60-61` (operator documentation) +- Test: `test/unit/external_events/discord_killmail_age_test.exs` + +**Interfaces:** +- Produces: `WandererApp.Env.discord_startup_grace_seconds/0` โ†’ `non_neg_integer()`, default `600`. +- Produces: `WandererApp.Env.discord_startup_max_killmail_age_seconds/0` โ†’ `pos_integer()`, default `120`. +- Both are read by Task 4. Neither is cached, matching `discord_max_killmail_age_seconds/0`. +- Produces: two release-configurable environment variables, `WANDERER_DISCORD_STARTUP_GRACE_SECONDS` and `WANDERER_DISCORD_STARTUP_MAX_KILLMAIL_AGE_SECONDS`. An accessor without runtime wiring is not configurable in a release โ€” `config/runtime.exs` is the only file read at boot โ€” so the wiring is part of this task, not of the final documentation sweep. + +- [ ] **Step 1: Write the failing tests** + +Append a new `describe` block to `test/unit/external_events/discord_killmail_age_test.exs`. The existing `put_max_age/1` helper hardcodes one key, so add a general one beside it (place it directly after `put_max_age/1` at line 24): + +```elixir + defp put_key(key, value) do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.put(original, key, value) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + :ok + end + + defp delete_key(key) do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Keyword.delete(original, key) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + :ok + end +``` + +Then the tests: + +```elixir + describe "Env.discord_startup_grace_seconds/0" do + test "defaults to 600 when the key is absent" do + delete_key(:discord_startup_grace_seconds) + + assert Env.discord_startup_grace_seconds() == 600 + end + + test "returns the configured value, not only the default" do + put_key(:discord_startup_grace_seconds, 90) + + assert Env.discord_startup_grace_seconds() == 90 + end + + # THE test for this key. `0` means "no startup window" and must be honoured + # rather than treated as a misconfiguration. Routing this key through + # `validate_positive_integer/3` would return 600 here and turn an operator's + # "disabled" into ten minutes of tightened freshness -- and would break + # config/test.exs, which uses exactly this value. + test "honours zero as 'window disabled' rather than falling back" do + put_key(:discord_startup_grace_seconds, 0) + + log = + capture_log(fn -> + assert Env.discord_startup_grace_seconds() == 0 + end) + + refute log =~ "discord_startup_grace_seconds" + end + + test "falls back to the default and warns when configured as negative" do + put_key(:discord_startup_grace_seconds, -1) + + log = + capture_log(fn -> + assert Env.discord_startup_grace_seconds() == 600 + end) + + assert log =~ "discord_startup_grace_seconds" + end + + test "falls back to the default and warns when configured as a non-integer" do + put_key(:discord_startup_grace_seconds, "ten minutes") + + log = + capture_log(fn -> + assert Env.discord_startup_grace_seconds() == 600 + end) + + assert log =~ "discord_startup_grace_seconds" + end + end + + describe "Env.discord_startup_max_killmail_age_seconds/0" do + test "defaults to 120 when the key is absent" do + delete_key(:discord_startup_max_killmail_age_seconds) + + assert Env.discord_startup_max_killmail_age_seconds() == 120 + end + + test "returns the configured value, not only the default" do + put_key(:discord_startup_max_killmail_age_seconds, 45) + + assert Env.discord_startup_max_killmail_age_seconds() == 45 + end + + # The opposite of the grace key: here `0` IS a misconfiguration, because a + # kill that has already happened always has a non-negative age and the guard + # keeps a kill only when `age <= max`. + test "falls back to the default and warns when configured as zero" do + put_key(:discord_startup_max_killmail_age_seconds, 0) + + log = + capture_log(fn -> + assert Env.discord_startup_max_killmail_age_seconds() == 120 + end) + + assert log =~ "discord_startup_max_killmail_age_seconds" + end + + test "falls back to the default and warns when configured as a non-integer" do + put_key(:discord_startup_max_killmail_age_seconds, :soon) + + log = + capture_log(fn -> + assert Env.discord_startup_max_killmail_age_seconds() == 120 + end) + + assert log =~ "discord_startup_max_killmail_age_seconds" + end + end +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `mix test test/unit/external_events/discord_killmail_age_test.exs` + +Expected: FAIL with `UndefinedFunctionError` on `WandererApp.Env.discord_startup_grace_seconds/0`. + +- [ ] **Step 3: Add the accessors and the new validator** + +In `lib/wanderer_app/env.ex`, after `discord_max_killmail_age_seconds/0` (line 127): + +```elixir + @default_discord_startup_grace_seconds 600 + @default_discord_startup_max_killmail_age_seconds 120 + + @doc """ + How long after the Discord dedup marks are lost the tighter startup maximum + age applies, in seconds. `0` disables the window. + + The marks live in `:discord_dedup_cache`, which is memory-only, so a restart + loses every one of them. The kills client then rejoins its channel and the + upstream service replays recent killmails, which the ordinary 3600-second + freshness limit happily admits โ€” an hour of already-posted kills, posted + again. During this window `discord_startup_max_killmail_age_seconds/0` + applies instead. + + Ten minutes rather than two because a long window is nearly free: it only + ever drops *old* killmails. The replay burst arrives when the kills client + joins the channel, which can be minutes after boot when the upstream service + is slow to accept the connection, and a short window would miss it. + + Validated as NON-NEGATIVE, unlike its sibling below. `0` is a legitimate + setting meaning "no startup window", and `validate_positive_integer/3` would + turn an operator's "disabled" into #{@default_discord_startup_grace_seconds} + seconds plus a warning โ€” the opposite of what they asked for. + """ + def discord_startup_grace_seconds() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_startup_grace_seconds, @default_discord_startup_grace_seconds) + |> validate_non_negative_integer( + :discord_startup_grace_seconds, + @default_discord_startup_grace_seconds + ) + end + + @doc """ + Maximum killmail age, in seconds, while the startup window is armed. + + Validated as POSITIVE, like `discord_max_killmail_age_seconds/0` and for the + same reason: a kill that has already happened always has a non-negative age + and the guard keeps a kill only when `age <= max`, so `0` or a negative value + would silently and invisibly suppress every notification. + + The accepted cost of the tighter limit is that a genuinely delayed killmail โ€” + upstream lag beyond this many seconds โ€” is dropped during the window. That is + the same trade the dispatcher already makes for at-most-once dedup: a dropped + kill stays visible in the kills widget and on zKillboard, while a duplicate + post in a chat channel is irreversible. + """ + def discord_startup_max_killmail_age_seconds() do + Application.get_env(@app, :external_events, []) + |> Keyword.get( + :discord_startup_max_killmail_age_seconds, + @default_discord_startup_max_killmail_age_seconds + ) + |> validate_positive_integer( + :discord_startup_max_killmail_age_seconds, + @default_discord_startup_max_killmail_age_seconds + ) + end +``` + +And add the validator beside `validate_positive_integer/3` (after line 295): + +```elixir + # Sibling of `validate_positive_integer/3` for settings where `0` is a + # legitimate value meaning "off" rather than a misconfiguration. Both fall + # back loudly rather than silently. + defp validate_non_negative_integer(value, _key, _default) + when is_integer(value) and value >= 0, + do: value + + defp validate_non_negative_integer(value, key, default) do + Logger.warning( + "[Discord] #{key} must be a non-negative integer, " <> + "got #{inspect(value)}; falling back to #{default}" + ) + + default + end +``` + +- [ ] **Step 4: Disable the window under test** + +In `config/test.exs`, change line 36: + +```elixir + external_events: [ + webhooks_enabled: false, + # `0` disables the startup window, which the non-negative validator honours. + # Without this, every test calling `start_supervised!(DiscordDispatcher)` + # (discord_dispatcher_test.exs:110) would begin inside a live 600-second + # grace period, and the existing age assertions in + # discord_killmail_age_test.exs would quietly start measuring against 120 + # seconds instead of 3600. Tests that exercise the window set it explicitly. + discord_startup_grace_seconds: 0 + ], +``` + +- [ ] **Step 5: Wire both keys into the release runtime configuration** + +`config/test.exs` and `config/dev.exs` are compile-time files; a release reads **only** `config/runtime.exs`. Without this step both keys are permanently pinned to their defaults in production and the operator-facing documentation added below would describe variables that do nothing. + +In `config/runtime.exs`, inside the existing `config :wanderer_app, :external_events,` block, add both keys directly after `discord_max_killmail_age_seconds` (line 497-499) so the three age-related settings stay together: + +```elixir + discord_startup_grace_seconds: + config_dir + |> get_int_from_path_or_env("WANDERER_DISCORD_STARTUP_GRACE_SECONDS", 600), + discord_startup_max_killmail_age_seconds: + config_dir + |> get_int_from_path_or_env("WANDERER_DISCORD_STARTUP_MAX_KILLMAIL_AGE_SECONDS", 120), +``` + +Repeat the defaults here rather than reaching for the `Env` module attributes: `runtime.exs` runs before the application is loaded and cannot call into `WandererApp.Env`. The two defaults are therefore stated twice, and the final gate checks that they still agree. + +`get_int_from_path_or_env/3` returns an integer or the default, so a non-numeric value never reaches the accessor. The accessors still validate, because `config/test.exs`, `dev.exs`, and any operator calling `Application.put_env/3` bypass this path entirely. + +- [ ] **Step 6: Document both variables** + +In `.env.example`, after the `WANDERER_DISCORD_MAX_KILLMAIL_AGE_SECONDS` block (lines 24-27): + +```bash +# After the in-memory Discord dedup marks are lost, the tighter age limit below +# applies for this many seconds, so an upstream replay of already-posted kills +# is dropped for being old. 0 disables the window. (optional, default 600) +# export WANDERER_DISCORD_STARTUP_GRACE_SECONDS="600" +# Maximum killmail age while that window is armed. (optional, default 120) +# export WANDERER_DISCORD_STARTUP_MAX_KILLMAIL_AGE_SECONDS="120" +``` + +In `README.md`, after the `WANDERER_DISCORD_MAX_KILLMAIL_AGE_SECONDS` sentence (line 60-61): + +```markdown +The dedup marks that stop a killmail being posted twice are held in memory, so a +restart loses them and the upstream service then replays recent kills. For +`WANDERER_DISCORD_STARTUP_GRACE_SECONDS` (default `600`) after the marks are +lost, `WANDERER_DISCORD_STARTUP_MAX_KILLMAIL_AGE_SECONDS` (default `120`) +applies instead of the hour above, so the replayed history is dropped for being +old while a kill that genuinely happens during the window still posts. Set the +grace to `0` to disable the window. +``` + +- [ ] **Step 7: Run the tests and confirm they pass** + +Run: `mix test test/unit/external_events/discord_killmail_age_test.exs` + +Expected: PASS, including the pre-existing `discord_max_killmail_age_seconds/0` tests. + +- [ ] **Step 8: Format and commit** + +```bash +mix format +git add lib/wanderer_app/env.ex config/runtime.exs config/test.exs .env.example README.md test/unit/external_events/discord_killmail_age_test.exs +git commit -m "feat(discord): config for the killmail startup grace window + +Two keys, deliberately not sharing a validator. + +discord_startup_grace_seconds (default 600) is non-negative: 0 is a +legitimate 'no startup window', and the positive-integer validator would +turn that into 600 seconds plus a warning. + +discord_startup_max_killmail_age_seconds (default 120) keeps the +positive-integer validator, for the same reason the ordinary max age +does: 0 would silently suppress every notification. + +Both are wired through config/runtime.exs, which is the only config file +a release reads -- an Env accessor alone would leave them pinned to +their defaults in production. + +config/test.exs sets the grace to 0 so existing dispatcher tests are not +silently pulled inside a live window." +``` + +--- + +## Task 4: Arm the startup window from a dedup-cache sentinel + +**The window belongs to the dedup cache's lifecycle, not the dispatcher's.** `:discord_dedup_cache` and `DiscordDispatcher` are separate children of a `:one_for_one` supervisor (`lib/wanderer_app/application.ex:150-154`, `:270-298`), so their restarts are independent: + +| Event | Marks | Window must | +|---|---|---| +| Full application restart | lost | arm | +| Dedup cache crashes alone | lost | **arm** | +| Dispatcher crashes alone | intact | not arm (harmless if it does) | +| Kills-client reconnect, no restart | intact | not arm | + +Keying the window off `DiscordDispatcher.init/1` gets row 2 exactly backwards: every mark is gone and the window never arms โ€” precisely the duplicate-post scenario this exists to prevent. So the window is derived from a sentinel stored **in the dedup cache itself**: absent means the cache is new, so its marks are gone. + +**And the sentinel is read once per kill batch, not once at dispatcher start.** This is the same argument applied a second time, and an earlier draft of this plan got it wrong in the same way the design did. Caching the deadline in dispatcher state reintroduces the dispatcher's lifecycle through the back door: in row 2 the cache restarts alone, so nothing calls `init/1`, and a long-lived dispatcher holds a stale deadline โ€” or `:never` โ€” forever while every mark is gone. Reading it per batch makes row 2 work, and it is *simpler*: no state field, no `init/1` change, and `do_dispatch/2` keeps its arity across all three clauses. + +The cost is one `Cachex.get/2` per `:map_kill` batch โ€” an ETS read on a path that already does a database read and an HTTP post. It does not violate the once-per-batch config constraint: `Env.discord_startup_grace_seconds/0` is read only on the arming branch, which runs once per cache lifetime, not once per batch. + +A second consequence is a small improvement. The window now starts when kills actually begin flowing rather than at boot, so a slow kills-client handshake no longer eats into it. + +The sentinel carries an **absolute deadline**, not a TTL. An expired window is still a *present* sentinel, so nothing can re-arm it while the cache lives. Monotonic time, because the cache and the dispatcher share a VM and it is immune to wall-clock adjustment. + +**Files:** +- Modify: `lib/wanderer_app/external_events/discord_dispatcher.ex` (the `:map_kill` clause at `:223` only) +- Test: `test/unit/external_events/discord_startup_window_test.exs` (create) + +**Interfaces:** +- Consumes: `Env.discord_startup_grace_seconds/0` and `Env.discord_startup_max_killmail_age_seconds/0` from Task 3. +- Consumes: `Cachex.persist/2`, which clears an entry's expiration. Required โ€” see Deviation 2 above. +- Produces: `DiscordDispatcher.startup_sentinel_key/0` โ†’ `String.t()`, public so tests derive the key instead of hardcoding it, matching `dedup_key/2` and `dedup_cache/0` (`discord_dispatcher.ex:899-905`). +- Produces: `DiscordDispatcher.arm_startup_grace/0` โ†’ `integer() | :never`. **Public**, and read-or-write rather than write: it returns the stored deadline when a sentinel is present and writes one only when it is absent. Public because it is the unit under test here โ€” a test that drives it directly exercises the real cache-only-restart path, which a test that restarts the dispatcher cannot reach. +- Produces: `DiscordDispatcher.within_startup_grace?/1` โ†’ `boolean()`. +- **No state change and no arity change.** Dispatcher state stays `%{}`, `init/1` is untouched, `handle_cast/2` is untouched, and all three `do_dispatch/2` clauses keep their arity. Nothing outside the `:map_kill` clause moves โ€” including the `do_dispatch/2` reference in the `start_enrichment` comment at `:425`, which stays correct. + +- [ ] **Step 1: Write the failing tests** + +Create `test/unit/external_events/discord_startup_window_test.exs`. This file owns the window's behaviour end-to-end; it does not reuse `discord_dispatcher_test.exs` because that file's setup deliberately runs with the window disabled. + +```elixir +defmodule WandererApp.ExternalEvents.DiscordStartupWindowTest do + # `async: false` is mandatory: this file mutates application env and shares + # the global `:discord_dedup_cache` with every other test. + use WandererApp.DataCase, async: false + + alias WandererApp.ExternalEvents.DiscordDispatcher + + # Restores the whole `:external_events` list, per the global constraint. + defp put_keys(pairs) do + original = Application.get_env(:wanderer_app, :external_events, []) + + Application.put_env( + :wanderer_app, + :external_events, + Enum.reduce(pairs, original, fn {k, v}, acc -> Keyword.put(acc, k, v) end) + ) + + on_exit(fn -> Application.put_env(:wanderer_app, :external_events, original) end) + :ok + end + + # `:discord_dedup_cache` is global and NOT sandboxed, so the sentinel written + # by any earlier test in the run is still there. Clearing it is what "the + # dedup cache is new" means, and every test here must start from a known + # state or it silently asserts nothing. + defp clear_sentinel do + Cachex.del(DiscordDispatcher.dedup_cache(), DiscordDispatcher.startup_sentinel_key()) + + on_exit(fn -> + Cachex.del(DiscordDispatcher.dedup_cache(), DiscordDispatcher.startup_sentinel_key()) + end) + + :ok + end + + describe "arming" do + # These drive `arm_startup_grace/0` directly rather than through + # `start_supervised!(DiscordDispatcher)`. That is the point: the window is + # armed per batch, so its behaviour has nothing to do with the dispatcher's + # lifecycle, and a test that restarted the dispatcher would be asserting + # against the wrong lifecycle -- the exact mistake an earlier draft made. + test "arms when the dedup cache carries no sentinel" do + put_keys(discord_startup_grace_seconds: 600) + clear_sentinel() + + arm_until = DiscordDispatcher.arm_startup_grace() + + assert is_integer(arm_until) + assert arm_until > System.monotonic_time(:millisecond) + end + + # Row 3 of the lifecycle table, and the case an earlier draft of the design + # got backwards. The marks survived, so the window must NOT re-arm -- + # otherwise every batch silently pushes the deadline further out and the + # window never closes at all. + # + # The grace is RAISED tenfold between the two calls, so a re-arm would move + # the deadline by about 5,400,000 ms. Comparing two calls made under the + # same grace would instead hinge on millisecond resolution, and would pass + # by coincidence whenever both landed in the same millisecond. + test "does not re-arm when the sentinel is already present" do + put_keys(discord_startup_grace_seconds: 600) + clear_sentinel() + + first = DiscordDispatcher.arm_startup_grace() + + put_keys(discord_startup_grace_seconds: 6000) + + assert DiscordDispatcher.arm_startup_grace() == first + end + + # Row 2: the dedup cache crashed alone, taking every mark with it. Nothing + # restarted the dispatcher. Same tenfold grace change, so the assertion + # cannot pass on a same-millisecond coincidence in either direction. + test "re-arms after the sentinel is cleared" do + put_keys(discord_startup_grace_seconds: 600) + clear_sentinel() + + first = DiscordDispatcher.arm_startup_grace() + + Cachex.del(DiscordDispatcher.dedup_cache(), DiscordDispatcher.startup_sentinel_key()) + put_keys(discord_startup_grace_seconds: 6000) + + assert DiscordDispatcher.arm_startup_grace() - first > 5_000_000 + end + + test "a zero grace period leaves the window closed immediately" do + put_keys(discord_startup_grace_seconds: 0) + clear_sentinel() + + refute DiscordDispatcher.within_startup_grace?(DiscordDispatcher.arm_startup_grace()) + end + + # Guards Deviation 2: `:discord_dedup_cache` has a 24h default_ttl, and + # `Cachex.put/4` honours only an integer `:ttl`, so a bare put would leave + # the sentinel expiring after a day -- after which the window would + # spuriously re-arm on a healthy cache that never lost a mark. + test "the sentinel never expires" do + put_keys(discord_startup_grace_seconds: 600) + clear_sentinel() + + DiscordDispatcher.arm_startup_grace() + + assert Cachex.ttl( + DiscordDispatcher.dedup_cache(), + DiscordDispatcher.startup_sentinel_key() + ) == {:ok, nil} + end + end + + describe "within_startup_grace?/1" do + test "an armed deadline in the future is inside the window" do + assert DiscordDispatcher.within_startup_grace?(System.monotonic_time(:millisecond) + 60_000) + end + + test "a deadline in the past is outside the window" do + refute DiscordDispatcher.within_startup_grace?(System.monotonic_time(:millisecond) - 1) + end + + # The value the dispatcher falls back to when the sentinel is unreadable. + # A plain integer would be wrong here: Erlang monotonic time may be + # negative, so `0` is not reliably "in the past". + test "an unarmed window is never inside it" do + refute DiscordDispatcher.within_startup_grace?(:never) + end + end +end +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `mix test test/unit/external_events/discord_startup_window_test.exs` + +Expected: FAIL with `UndefinedFunctionError` on `DiscordDispatcher.startup_sentinel_key/0`. + +- [ ] **Step 3: Add the sentinel, the arming function, and the window predicate** + +In `lib/wanderer_app/external_events/discord_dispatcher.ex`, add the attribute next to `@dedup_ttl` (after line 70): + +```elixir + # Lives in the dedup cache rather than the dispatcher's own state ON PURPOSE: + # the window exists because the dedup MARKS are gone, and those marks belong + # to that cache. The two are separate children of a `:one_for_one` supervisor, + # so a dedup-cache-only crash loses every mark while the dispatcher keeps + # running -- exactly the case a dispatcher-lifecycle window would miss. + # + # Cannot collide with a dedup key: those are `"\#{map_id}:\#{killmail_id}"` + # with a UUID map_id, and this contains no colon. + @startup_sentinel "discord-startup-grace-until" +``` + +Add the three public functions beside `dedup_cache/0` (after line 905): + +```elixir + @doc "Name of the startup-window sentinel key, so tests do not hardcode it." + @spec startup_sentinel_key() :: String.t() + def startup_sentinel_key, do: @startup_sentinel + + @doc """ + Reads the startup-window deadline from the dedup cache, writing one if it is + absent. Idempotent, and called once per kill batch. + + ABSENT means the cache is new and its marks are gone, so arm. PRESENT means + the cache survived, so honour the stored deadline as it is -- including an + expired one. The deadline is absolute, not a TTL, precisely so that an expired + window is still a *present* sentinel and nothing re-arms it while the cache + lives. + + Called per batch rather than from `init/1` because the cache and the + dispatcher are independent children of a `:one_for_one` supervisor. A + dedup-cache-only crash never runs `init/1`, so a deadline cached in dispatcher + state would go stale in exactly the scenario this window exists for. + + Public because it is the unit under test: driving it directly is the only way + to exercise a cache-only restart, which restarting the dispatcher cannot + reach. + """ + @spec arm_startup_grace() :: integer() | :never + def arm_startup_grace do + case Cachex.get(@dedup_cache, @startup_sentinel) do + {:ok, nil} -> + # The only `Env` read on this path, and it runs once per cache + # lifetime, not once per batch -- so the once-per-batch config + # constraint holds. + arm_until = + System.monotonic_time(:millisecond) + Env.discord_startup_grace_seconds() * 1000 + + Cachex.put(@dedup_cache, @startup_sentinel, arm_until) + # REQUIRED, not belt-and-braces. This cache is created with + # `default_ttl: :timer.hours(24)` (`application.ex:150-154`), and + # `Cachex.Actions.Put.execute/4` honours only an INTEGER `:ttl` -- + # `nil` falls back to the cache default. Without this the sentinel + # would expire after a day of uptime and the window would spuriously + # re-arm on a healthy cache that never lost a mark. + Cachex.persist(@dedup_cache, @startup_sentinel) + + arm_until + + {:ok, arm_until} when is_integer(arm_until) -> + arm_until + + _ -> + :never + end + rescue + # `Cachex.get/2` raises against an unstarted cache. Failing to read the + # sentinel must leave the window CLOSED, not open: an unreadable cache is + # not evidence that marks were lost, and arming on it would suppress real + # killmails. Fail-open here means "do not suppress". + _ -> :never + end + + @doc """ + Whether a batch is inside the startup grace window. + + `:never` rather than a sentinel integer for the unarmed case: Erlang + monotonic time may be negative, so no integer is reliably "in the past". + """ + @spec within_startup_grace?(integer() | :never) :: boolean() + def within_startup_grace?(:never), do: false + + def within_startup_grace?(arm_until) when is_integer(arm_until), + do: System.monotonic_time(:millisecond) < arm_until +``` + +- [ ] **Step 4: Resolve the window once per batch in the `:map_kill` clause** + +Everything in this step is inside the one `do_dispatch/2` clause at line 223. `init/1`, `handle_cast/2`, and the other two `do_dispatch/2` clauses are **not** touched, and no arity changes. + +**Move** the `max_killmail_age_seconds` binding out of the `with` chain and above it, alongside `now`. It has to move rather than change in place: a multi-line `if/do/end` cannot be a `with` clause, and neither can the one-line `if cond, do: a, else: b` โ€” the trailing comma that separates `with` clauses is swallowed by the `if`'s keyword list, and the compiler rejects it with *"unexpected expression after keyword list"*. Both forms were verified against `elixir 1.17.3`. Neither depends on anything the `with` binds, so hoisting is free. + +Delete the `max_killmail_age_seconds = Env.discord_max_killmail_age_seconds(),` clause (line 237) but **keep the long comment above it** โ€” move it up with the binding. The result, replacing `now = DateTime.utc_now()` at line 224: + +```elixir + now = DateTime.utc_now() + + # One ETS read per batch, deliberately NOT cached in dispatcher state: the + # dedup cache and this GenServer are independent children of a + # `:one_for_one` supervisor, so a cache-only crash would leave cached state + # stale in exactly the scenario the window exists for. + startup_arm_until = arm_startup_grace() + startup? = within_startup_grace?(startup_arm_until) + + # Resolved ONCE per batch, not per kill: `kill_fresh?/3` runs once per + # killmail below, and re-reading (and re-validating) config on every one of + # potentially dozens of kills would turn a single misconfigured deployment + # into a warning-per-kill log flood. This binding, and the explicit third + # argument to `kill_fresh?/3` below, must survive any rewrite of this + # `with` chain -- dropping either silently reopens that flood. Filtering + # for age happens ONCE, before partitioning: moving it inside the + # per-destination loop reintroduces the flood. + # + # The branch picks WHICH accessor to call. It does not move the call + # per-kill, and it is deliberately outside the `with` chain -- an `if` + # cannot be a `with` clause in either its block or its keyword form. + ordinary_max_age_seconds = Env.discord_max_killmail_age_seconds() + + max_killmail_age_seconds = + if startup? do + Env.discord_startup_max_killmail_age_seconds() + else + ordinary_max_age_seconds + end +``` + +`ordinary_max_age_seconds` is bound unconditionally, and inside the window it is bound *in addition to* the tighter limit. Task 5 needs both to tell a kill the window suppressed from one the ordinary limit would have dropped anyway. Outside the window the two are the same value and the second `if` is a no-op. + +Trim the surviving in-chain comment above `recent` so it no longer refers to a binding that is not there โ€” it keeps only the "filtered BEFORE dedup" paragraph, which is still about the clause it sits on. + +Finally, add one line to `kill_fresh?/3`'s doc after the `max_age_seconds` paragraph: + +```elixir + During the startup grace window the caller passes + `Env.discord_startup_max_killmail_age_seconds/0` instead. This function is + unchanged by that: it already takes the maximum as an explicit argument, so + both call paths flow through the same comparison. +``` + +Leave every `do_dispatch/2` reference in the module's comments alone โ€” the arity has not changed. + +- [ ] **Step 5: Run the new tests and confirm they pass** + +Run: `mix test test/unit/external_events/discord_startup_window_test.exs` + +Expected: PASS. + +- [ ] **Step 6: Run the full Discord suite for regressions** + +Run: `mix test test/unit/external_events/` + +Expected: PASS. `config/test.exs` sets the grace to `0`, so every pre-existing test resolves `Env.discord_max_killmail_age_seconds/0` exactly as before. + +If `discord_dispatcher_test.exs` fails with kills unexpectedly dropped, the cause is almost certainly a leaked sentinel: an earlier test in the same run armed a live window and `config/test.exs` was not picked up. Check the grace value actually in effect rather than loosening an assertion. + +- [ ] **Step 7: Format and commit** + +```bash +mix format +git add lib/wanderer_app/external_events/discord_dispatcher.ex test/unit/external_events/discord_startup_window_test.exs +git commit -m "fix(discord): suppress replayed killmails after a restart + +The dedup marks live in an in-memory Cachex, so a restart loses every +one. The kills client then rejoins its channel, the upstream service +replays recent killmails, and the ordinary 3600-second freshness limit +admits an hour of already-posted kills. + +For a grace window after the marks are lost, the freshness filter uses a +much tighter maximum age instead. Replayed history is dropped because it +is old; a killmail that genuinely occurs during the window still posts. + +The window is derived from a sentinel in the dedup cache, not from the +dispatcher's own lifecycle, and it is read once per kill batch rather +than at dispatcher start. The cache and the dispatcher are separate +children of a one_for_one supervisor: a dedup-cache-only crash loses +every mark while the dispatcher keeps running, so anything keyed to the +dispatcher's lifecycle -- including a deadline cached in its state at +init -- goes stale in exactly that case. + +The sentinel holds an absolute monotonic deadline rather than a TTL, so +an expired window is still a present sentinel and nothing re-arms it +while the cache lives." +``` + +--- + +## Task 5: Make dropped killmails visible + +A killmail dropped by the startup window currently leaves **no trace at all**: the age filter falls out of the `with` chain into a catch-all `:ok` (`discord_dispatcher.ex:267-269`), and telemetry is emitted only after delivery or an enqueue failure (`:762-766`, `:794-800`). During an incident, "did we suppress it, or did we never receive it?" would be unanswerable โ€” the one question this feature makes worth asking. + +Three reasons, not one: conflating them defeats the purpose. `:startup_age` is the new suppression, `:age` is the pre-existing hour limit, `:duplicate` is ordinary dedup. + +**A drop is classified per kill, against both thresholds โ€” not by whether the window happens to be open.** During the window a kill can fail the tighter limit for either of two reasons, and they are not the same event: a five-minute-old kill is one the window suppressed, while a two-hour-old kill would have been dropped by the pre-existing 3600-second limit with or without the window. Labelling both `:startup_age` would inflate the count of "kills the window suppressed" with kills it had nothing to do with โ€” in exactly the metric an operator would read to decide the window is too aggressive, and at exactly the moment they would read it. So each dropped kill is re-tested against the ordinary limit: passes it, `:startup_age`; fails it too, `:age`. Outside the window the two limits are the same value, so every drop classifies as `:age` and the branch costs nothing. + +**Files:** +- Modify: `lib/wanderer_app/external_events/discord_dispatcher.ex` (the `:map_kill` clause, and near `reject_duplicates/2` at `:854-873`) +- Test: `test/unit/external_events/discord_startup_window_test.exs` + +**Interfaces:** +- Consumes: the `startup_arm_until`, `startup?`, `max_killmail_age_seconds`, and `ordinary_max_age_seconds` bindings from Task 4. +- Produces: telemetry event `[:wanderer_app, :discord_dispatcher, :killmail_dropped]`, measurements `%{count: pos_integer()}`, metadata `%{map_id: String.t(), reason: :startup_age | :age | :duplicate}`. Emitted only when `count > 0`. +- Note the event prefix is `:discord_dispatcher`, matching `:dispatched` and `:not_delivered` โ€” see Deviation 1. +- Produces: one `Logger.info` per batch when `:startup_age` drops anything, carrying the count and the **remaining** window in seconds. Not asserted by the tests below โ€” the telemetry event is the contract; the log line is for a human reading a boot log. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/unit/external_events/discord_startup_window_test.exs`. This needs the dispatcher's full delivery fixture, so bring in the setup pieces it depends on from `discord_dispatcher_test.exs`. + +ExUnit forbids `def`/`defp` inside a `describe` block, so every helper below goes at module level, above the `describe`. Add these aliases to the ones already at the top of the file from Task 4: + +```elixir + alias WandererApp.ExternalEvents.Event + alias WandererApp.ExternalEvents.Discord.{HttpStub, WorkerSupervisor} + alias WandererAppWeb.Factory +``` + +Module-level helpers: + +```elixir + # Mirrors discord_dispatcher_test.exs:185-204. `wh_only` filtering resolves + # the system class through this cache, and the table behind it is static + # import data that `mix test` does not populate. + defp seed_static_info do + Cachex.put(:system_static_info_cache, 31_000_005, %{ + solar_system_id: 31_000_005, + solar_system_name: "J115405", + system_class: 3 + }) + + on_exit(fn -> Cachex.del(:system_static_info_cache, 31_000_005) end) + :ok + end + + # Closes the window without touching the dispatcher. Because the sentinel is + # read per batch, a zero grace plus a cleared sentinel means the NEXT batch + # arms a deadline that is already in the past. Both halves are needed: a + # surviving sentinel would make the batch reuse the already-armed 600s + # deadline from `setup` and the config change would do nothing. + defp close_window do + put_keys(discord_startup_grace_seconds: 0) + Cachex.del(DiscordDispatcher.dedup_cache(), DiscordDispatcher.startup_sentinel_key()) + :ok + end + + # Collects every drop event raised while `fun` runs, as {reason, count}. + # Dispatch is a cast, so drain the dispatcher's mailbox before reading. + defp capture_drops(fun) do + {:ok, agent} = Agent.start_link(fn -> [] end) + handler_id = {__MODULE__, System.unique_integer([:positive])} + + :telemetry.attach( + handler_id, + [:wanderer_app, :discord_dispatcher, :killmail_dropped], + fn _event, measurements, metadata, _config -> + Agent.update(agent, &[{metadata.reason, measurements.count} | &1]) + end, + nil + ) + + try do + fun.() + :sys.get_state(DiscordDispatcher) + Agent.get(agent, &Enum.reverse/1) + after + :telemetry.detach(handler_id) + Agent.stop(agent) + end + end + + # Mirrors discord_dispatcher_test.exs:325-342. Absence of a drop event is NOT + # evidence of delivery -- a kill silently lost anywhere else on the path would + # satisfy `drops == []` just as well. The tests that claim a kill was + # delivered assert an actual outbound request. + defp wait_for_requests(count, timeout \\ 2_000) do + do_wait(count, System.monotonic_time(:millisecond) + timeout) + end + + defp do_wait(count, deadline) do + cond do + length(HttpStub.requests()) >= count -> + HttpStub.requests() + + System.monotonic_time(:millisecond) > deadline -> + flunk("expected #{count} requests, got #{length(HttpStub.requests())}") + + true -> + Process.sleep(25) + do_wait(count, deadline) + end + end + + defp aged_kill(id, seconds_ago) do + Factory.build(:killmail, %{ + "killmail_id" => id, + "solar_system_id" => 31_000_005, + "kill_time" => + DateTime.utc_now() |> DateTime.add(-seconds_ago, :second) |> DateTime.to_iso8601() + }) + end + + defp dispatch(map_id, kills) do + payload = Factory.build(:kill_event, %{solar_system_id: 31_000_005, killmails: kills}) + + DiscordDispatcher.dispatch_event(map_id, %Event{ + map_id: nil, + type: :map_kill, + payload: payload + }) + end +``` + +The tests: + +```elixir + describe "drop telemetry" do + setup do + seed_static_info() + + # `config/test.exs` sets `webhooks_enabled: false` and the dispatcher + # reads it at call time, so without this override every assertion below + # would pass while dispatching nothing. + put_keys(webhooks_enabled: true, discord_startup_grace_seconds: 600) + clear_sentinel() + + HttpStub.start() + HttpStub.reset() + start_supervised!(WorkerSupervisor) + start_supervised!(DiscordDispatcher) + + map = Factory.insert(:map, %{}) + + {:ok, _notification} = + WandererApp.Api.MapDiscordNotification.create(%{ + map_id: map.id, + webhook_url: "https://discord.com/api/webhooks/123/tok" + }) + + DiscordDispatcher.invalidate_cache(map.id) + + %{map: map} + end + + # 5 minutes old: inside the ordinary 3600s limit, outside the 120s startup + # limit. This is the exact killmail the window exists to suppress. + test "a kill dropped by the startup window reports :startup_age", %{map: map} do + drops = capture_drops(fn -> dispatch(map.id, [aged_kill(5001, 300)]) end) + + assert drops == [{:startup_age, 1}] + assert HttpStub.requests() == [] + end + + # The same kill, with the window closed, is delivered -- proving the drop + # above is the window's doing and not the ordinary limit. Asserting the + # request, not merely the absence of a drop event: a kill lost anywhere + # else on the path would satisfy `drops == []` and make this test vacuous. + test "the same kill is not dropped once the window has closed", %{map: map} do + close_window() + + drops = capture_drops(fn -> dispatch(map.id, [aged_kill(5002, 300)]) end) + + assert drops == [] + assert length(wait_for_requests(1)) == 1 + end + + # Two hours old: outside BOTH limits. THE test for per-kill classification + # -- it runs with the window WIDE OPEN, because that is the only condition + # under which the two reasons can be confused. Classifying by `startup?` + # alone reports `:startup_age` here and inflates the window's apparent + # impact with a kill the pre-existing hour limit would have dropped anyway. + test "a kill older than the ordinary limit reports :age even inside the window", + %{map: map} do + assert DiscordDispatcher.within_startup_grace?(DiscordDispatcher.arm_startup_grace()) + + drops = capture_drops(fn -> dispatch(map.id, [aged_kill(5003, 7200)]) end) + + assert drops == [{:age, 1}] + end + + # Both reasons in one batch, so the counts cannot be right by accident: + # a single classification for the whole batch produces `[{:startup_age, 2}]` + # or `[{:age, 2}]`, never this. + test "a mixed batch splits the two age reasons", %{map: map} do + drops = + capture_drops(fn -> + dispatch(map.id, [aged_kill(5006, 300), aged_kill(5007, 7200)]) + end) + + assert Enum.sort(drops) == [{:age, 1}, {:startup_age, 1}] + end + + test "a repeated kill reports :duplicate", %{map: map} do + close_window() + kill = aged_kill(5004, 10) + + on_exit(fn -> + Cachex.del(DiscordDispatcher.dedup_cache(), DiscordDispatcher.dedup_key(map.id, 5004)) + end) + + capture_drops(fn -> dispatch(map.id, [kill]) end) + # The first dispatch must actually have been delivered and marked, or the + # second one is not a duplicate of anything. + assert length(wait_for_requests(1)) == 1 + + drops = capture_drops(fn -> dispatch(map.id, [kill]) end) + + assert drops == [{:duplicate, 1}] + end + + # No event at all when nothing is dropped: a counter that fires with + # `count: 0` on every healthy batch is noise that buries the real signal. + test "a fresh kill emits no drop event", %{map: map} do + close_window() + + drops = capture_drops(fn -> dispatch(map.id, [aged_kill(5005, 10)]) end) + + assert drops == [] + assert length(wait_for_requests(1)) == 1 + end + end +``` + +- [ ] **Step 2: Run the tests and confirm they fail** + +Run: `mix test test/unit/external_events/discord_startup_window_test.exs` + +Expected: exactly four FAIL โ€” `:startup_age`, `:age`-inside-the-window, the mixed batch, and `:duplicate` each assert an event where nothing is emitted yet. + +The two that assert `drops == []` PASS at this stage: nothing emits anything, and Task 4 already delivers those kills, so their `wait_for_requests(1)` is satisfied too. They are regression pins, not drivers, and their passing now is expected โ€” do not "fix" them. + +- [ ] **Step 3: Extract the two filters and emit** + +In `lib/wanderer_app/external_events/discord_dispatcher.ex`, both filters sit inline in the `with` chain. Move each behind a helper that counts what it removed. + +First, beside `startup?` above the `with` (added in Task 4), bind what the age filter needs beyond the limit it enforces. Two things: the **ordinary** limit, so a drop can be classified against both thresholds rather than by `startup?` alone, and the **remaining** window, because the spec asks the log line to carry it โ€” "how much longer will this keep happening?" is the operator's actual next question, and it is only derivable from the deadline: + +```elixir + drop_context = {ordinary_max_age_seconds, startup_grace_remaining_ms(startup_arm_until)} +``` + +Then change the two filter clauses in the chain: + +```elixir + [_ | _] = recent <- + filter_fresh(map_id, killmails, now, max_killmail_age_seconds, drop_context), + [_ | _] = fresh <- reject_duplicates_counted(map_id, recent) do +``` + +Add the remaining-time helper beside `within_startup_grace?/1`: + +```elixir + # Clamped at zero: a batch can land microseconds after the deadline while + # `startup?` was computed just before it, and a negative "seconds remaining" + # in a log line reads as a bug in the window rather than a rounding artifact. + defp startup_grace_remaining_ms(:never), do: 0 + + defp startup_grace_remaining_ms(arm_until) when is_integer(arm_until), + do: max(arm_until - System.monotonic_time(:millisecond), 0) +``` + +Add the two wrappers and the emitter beside `reject_duplicates/2` (after line 873): + +```elixir + # Wraps the age filter purely so the drop is counted. A kill dropped for age + # otherwise falls out of the `with` chain into its catch-all `:ok` and leaves + # no trace whatsoever, which makes "did we suppress it, or did we never + # receive it?" unanswerable during an incident. + defp filter_fresh(map_id, killmails, now, max_age_seconds, drop_context) do + {ordinary_max_age_seconds, remaining_ms} = drop_context + + {kept, dropped} = Enum.split_with(killmails, &kill_fresh?(&1, now, max_age_seconds)) + + # Classified PER KILL against the ORDINARY limit, not by whether the window + # is open. Inside the window a kill can fail the tighter limit for either + # of two reasons: the window suppressed it, or it was old enough that the + # pre-existing hour limit would have dropped it anyway. Labelling both + # `:startup_age` inflates "kills the window suppressed" with kills the + # window had nothing to do with -- in exactly the metric an operator reads + # to judge whether the window is too aggressive. + # + # Outside the window `max_age_seconds == ordinary_max_age_seconds`, so + # every dropped kill fails this second test too and classifies as `:age`. + # No branch on `startup?` is needed to get that: it falls out. + {startup_age, age} = + Enum.split_with(dropped, &kill_fresh?(&1, now, ordinary_max_age_seconds)) + + emit_dropped(map_id, length(startup_age), :startup_age) + emit_dropped(map_id, length(age), :age) + + # At info, not debug: this fires at most once per batch, only when the + # window actually suppressed something, and it is the line an operator + # searches for when a restart looks too quiet. Ordinary age and dedup drops + # stay telemetry-only -- they are steady-state behaviour, not an event. + if startup_age != [] do + Logger.info(fn -> + "[Discord] startup grace window suppressed #{length(startup_age)} " <> + "replayed killmail(s); #{div(remaining_ms, 1000)}s of the window remain" + end) + end + + kept + end + + defp reject_duplicates_counted(map_id, killmails) do + kept = reject_duplicates(map_id, killmails) + + emit_dropped(map_id, length(killmails) - length(kept), :duplicate) + + kept + end + + # Silent when nothing was dropped: a counter that fires with `count: 0` on + # every healthy batch buries the signal it exists to carry. + defp emit_dropped(_map_id, 0, _reason), do: :ok + + defp emit_dropped(map_id, count, reason) do + :telemetry.execute( + [:wanderer_app, :discord_dispatcher, :killmail_dropped], + %{count: count}, + %{map_id: map_id, reason: reason} + ) + + :ok + end +``` + +Leave `reject_duplicates/2` itself untouched โ€” its `seen` accumulator logic is load-bearing and this task must not disturb it. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `mix test test/unit/external_events/discord_startup_window_test.exs` + +Expected: PASS. + +- [ ] **Step 5: Run the full Discord suite** + +Run: `mix test test/unit/external_events/` + +Expected: PASS. + +- [ ] **Step 6: Format and commit** + +```bash +mix format +git add lib/wanderer_app/external_events/discord_dispatcher.ex test/unit/external_events/discord_startup_window_test.exs +git commit -m "feat(discord): report why a killmail was not posted + +A killmail dropped for age or as a duplicate left no trace at all: the +filters fall out of the with chain into a catch-all :ok, and telemetry +fired only after delivery or an enqueue failure. That made 'did we +suppress it, or did we never receive it?' unanswerable -- the one +question the new startup window makes worth asking. + +Emits [:wanderer_app, :discord_dispatcher, :killmail_dropped] with +%{count: n} and a reason of :startup_age, :age, or :duplicate. Three +reasons rather than one, because conflating the new suppression with the +pre-existing hour limit would defeat the point. Silent when nothing was +dropped. + +The two age reasons are classified PER KILL against both thresholds, not +by whether the window is open. Inside the window a kill old enough to +fail the pre-existing hour limit would have been dropped anyway, and +reporting it as :startup_age would inflate the window's apparent impact +in the one metric used to judge whether the window is too aggressive. + +Prefix is :discord_dispatcher, matching :dispatched and :not_delivered +-- a drop is a dispatch outcome. The :discord prefix in this module +belongs to the enrichment events. + +One throttled Logger.info per batch for :startup_age only." +``` + +--- + +## Final gate + +Run after all five tasks land, before opening a PR. + +- [ ] **Step 1: Full test suite** + +Run: `mix test` + +Expected: PASS. If something outside `external_events/` and `kills/` fails, suspect Task 1's blast radius first โ€” the index change affects the in-app kills widget. + +- [ ] **Step 2: Formatting, compile warnings, static analysis** + +```bash +mix format --check-formatted +mix compile --warnings-as-errors --force +mix credo --strict +mix dialyzer +``` + +Expected: clean. + +- [ ] **Step 3: Confirm the invariants survived** + +Read the final diff of `discord_dispatcher.ex` and check by eye: + +- No `Env.` call sits inside a per-killmail loop. The once-per-batch comments at `:230-237` and in `kill_fresh?/3`'s doc are intact and still accurate. +- `system_on_map?/2` and `arm_startup_grace/0` both still have their `rescue`, and both still fail in the *permissive* direction. +- `arm_startup_grace/0` is called from the `:map_kill` clause, **not** from `init/1`, and nothing caches its result in dispatcher state. A deadline cached at start-up goes stale precisely when the dedup cache restarts alone, which is the case the sentinel exists for. +- `reject_duplicates/2`'s `seen` accumulator is unchanged. +- Nothing moved a dedup mark to after delivery confirmation. +- `do_dispatch/2` still has arity 2 in all three clauses, and every reference to it in the module's comments โ€” including the one in `start_enrichment` at `:425` โ€” still reads `/2`. + +- [ ] **Step 4: Confirm the configuration is reachable end to end** + +Task 3 wires and documents both keys; this is the check that nothing was lost between there and here. Each accessor must trace all the way back to an environment variable: + +```bash +grep -rn "discord_startup_grace_seconds\|discord_startup_max_killmail_age_seconds" lib/ config/ +grep -rn "WANDERER_DISCORD_STARTUP" config/runtime.exs .env.example README.md +``` + +Expected, and each line is a distinct failure if missing: + +- Both keys appear in `lib/wanderer_app/env.ex` (the accessors) **and** in `config/runtime.exs` (the wiring). An accessor without wiring is pinned to its default in every release. +- Both `WANDERER_DISCORD_STARTUP_*` variables appear in all three of `config/runtime.exs`, `.env.example`, and `README.md`. +- The defaults in `config/runtime.exs` (`600` and `120`) match the `@default_discord_startup_*` attributes in `env.ex`. They are stated in two places because `runtime.exs` cannot call into the application, so they can drift. + +- [ ] **Step 5: Final commit** + +Only if Steps 1-4 turned up something to fix: + +```bash +mix format +git add -A +git commit -m "chore(discord): final-gate fixes for the startup grace window" +``` diff --git a/docs/superpowers/specs/2026-08-02-flyio-migration-design.md b/docs/superpowers/specs/2026-08-02-flyio-migration-design.md new file mode 100644 index 000000000..f88fb4a02 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-flyio-migration-design.md @@ -0,0 +1,618 @@ +# Migrating self-hosted Wanderer from docker-compose to Fly.io + +Date: 2026-08-02 +Revised: 2026-08-04 โ€” wanderer-kills brought into scope +Status: Design approved, not yet implemented + +## Goal + +Move a self-hosted Wanderer instance off a single VM running docker-compose +(wanderer + postgres + wanderer-kills + wanderer-notifier + caddy) onto Fly.io. + +Drivers, in the operator's own priority: less ops burden, easier recovery from +bad deploys, and a better deploy workflow. Cost is explicitly not a driver. + +## Scope + +In scope: the `wanderer` app, its Postgres database, and `wanderer-kills`. + +Also in scope: **an operator-facing deployment guide**, covering both a fresh Fly +install and the migration path, so other operators can reproduce this rather +than rediscovering it. It goes to `wanderer-industries/community-edition` โ€” the +self-hosting repository this repo's README already points at โ€” as a `fly-io/` +directory alongside the existing `reverse-proxy/` and `scripts/` topics, not +into this repository's `docs/`. The Fly-specific wanderer-kills material is +offered upstream for the same reason. + +wanderer-kills is in scope because it is a hard dependency for the operator's +users โ€” without it the map shows no kill data. Note the distinction that shapes +the plan below: it is a hard *product* dependency but a soft *runtime* one. The +websocket client degrades rather than crashing, which is what makes flexible +cutover ordering possible. + +Out of scope, deliberately: + +- Migrating wanderer-notifier. It becomes a sibling Fly app, but its migration + is separate work. This design fixes only the interface contract. +- PromEx / Grafana metrics. +- Multi-region, multi-machine, or HA Postgres. +- Any behavioural change to the application. + +## Repository evidence + +Findings from inspecting the checkout, which constrain the design. + +### Wanderer and its database + +- `fly.toml` already exists (app `wanderer-test`, region `ams`), so a Fly path + was started upstream but is not production-shaped. +- `config/runtime.exs` is already Fly-aware: it reads `FLY_APP_NAME` (line 16), + and supports `DATABASE_URL`, `DATABASE_SSL_ENABLED`, and `ECTO_IPV6`. +- Map state is held in node-local Cachex tables; character trackers register in + a node-local `Registry` (`WandererApp.Character.TrackerRegistry`); PubSub uses + the PG2 adapter with no clustering configured. **The app is single-node.** +- `WandererApp.ConfigHelpers.get_var_from_path_or_env/3` falls back to + `System.get_env` when `CONFIG_DIR` (default `/run/secrets`) is absent, so + `fly secrets` work for every setting with no shimming. +- `rel/overlays/bin/migrate.sh` runs `WandererApp.Release.interweave_migrate` + (`lib/wanderer_app/release.ex:72`), the correct Ash migration entry point. The + existing `release_command` is sound. +- `WandererApp.Repo.installed_extensions/0` (`lib/wanderer_app/repo.ex:5-8`) + returns only `["ash-functions"]` and no migration issues `CREATE EXTENSION`. + There is no native-extension risk in the dump/restore. + `min_pg_version/0` (`repo.ex:11-13`) requires PostgreSQL >= 15. +- The "Health Check Endpoints" scope at `lib/wanderer_app_web/router.ex:377-379` + is empty. There is no health route. + +### wanderer-kills + +- It lives in a **separate upstream repository** + (`wanderer-industries/wanderer-kills`), not in this one. There is no + docker-compose file here and no build for it. +- **It is stateless.** Its `mix.exs` declares no `ecto`, `ecto_sql`, `postgrex`, + or `redix` โ€” storage is `cachex` in memory only. Its `docker-compose.yml` + declares no volumes and no sidecar services. There is therefore no database to + provision and nothing to dump or restore; it rebuilds from zKillboard and ESI + on boot. +- It listens on a single port, **4004**, and already exposes `/health`. +- **It binds IPv4-only.** Upstream `config/config.exs` sets + `http: [port: 4004, ip: {0, 0, 0, 0}]`, and upstream `config/runtime.exs` + overrides only the port โ€” there is no `ip:` key and no bind-address + environment variable anywhere in it. Fly's 6PN `.internal` addresses are IPv6, + and Fly requires the server to listen on the 6PN address (or on `::`) to + receive them. **A client-side `:inet6` fix alone would therefore connect to an + address with no listener.** See blocking change 3. +- No published container image exists; it builds from source. +- Wanderer reaches it over a websocket (`WandererApp.Kills.Client`) at + `WANDERER_KILLS_BASE_URL`, defaulting to `ws://wanderer-kills:4004` + (`config/runtime.exs:72-74`). +- `WANDERER_KILLS_SERVICE_ENABLED` defaults to **`"false"`** + (`runtime.exs:67-70`). When false, neither `WandererApp.Kills.Supervisor` nor + `WandererApp.Map.ZkbDataFetcher` starts at all + (`lib/wanderer_app/application.ex:226-238`). +- **The dependency is one-directional.** Wanderer subscribes to kills and tells + it which systems to watch; kills never calls back into wanderer. This is what + allows the kills app to be deployed and warmed ahead of the cutover window. +- The client reconnects with exponential backoff (1s to a 60s ceiling, ~30% + jitter) but `@max_retries 10` then **stops retrying automatically**, falling + back to a 15-minute health-check cycle (`kills/client.ex:19-30`). + +### The websocket transport cannot currently reach a Fly private address + +This is the finding behind blocking change 2. Note it is necessary but not +sufficient on its own โ€” blocking change 3 covers the server side. Traced through +three files: + +1. `lib/wanderer_app/kills/client.ex:487-498` passes + `transport_opts: [timeout:, tcp_opts: [...]]`. +2. `deps/phoenix_gen_socket_client/lib/gen_socket_client/transport/web_socket_client.ex:18` + defines `@websocket_client_opts [:extra_headers, :ssl_verify]`. Line 30 + splits on **exactly those two keys**; everything else falls through into + `transport_options`, which line 34 passes as the *handler state* argument โ€” + not as socket options. +3. `deps/websocket_client/src/websocket_client.erl:195` reads + `socket_opts` from its options to build the transport. That is the key that + would work, and it is precisely the key being filtered out at step 2. + +Two consequences: + +- The existing `connect_timeout`, `send_timeout`, and `recv_timeout` values are + **already dead config today**. This is a pre-existing latent bug, independent + of Fly. +- There is **no supported path to pass `:inet6`**. Fly's `.internal` 6PN + addresses are IPv6-only, so `ws://wanderer-kills.internal:4004` would fail to + resolve. Erlang's `gen_tcp` defaults to the `inet` (IPv4) family for hostname + resolution. + +## Decisions + +### Postgres: Fly Managed Postgres in `iad` + +Considered: Fly Managed Postgres, Supabase (already in use by the operator), and +an unmanaged `fly pg` app. + +Chosen Fly MPG, co-located with the app in `iad`. Wanderer is chatty with the +database โ€” tracker pools writing character locations every 10-30s, audit rows, +signature and connection updates โ€” so per-query latency multiplies. MPG sits on +the same private network, and managed backups serve the "less ops burden" goal. + +Supabase was a genuine contender since the operator already runs it, but it adds +a public-internet hop on every query, and its transaction-mode pooler requires +`prepare: :unnamed` in the Repo config, which `runtime.exs` has no option for +today โ€” i.e. it would force a code change that MPG does not. + +An unmanaged `fly pg` app was rejected: it keeps the operator as DBA, which +contradicts the primary driver. + +### Exactly one machine, for both apps + +`auto_stop_machines = off`, `min_machines_running = 1`, no autoscaling, and +`DNS_CLUSTER_QUERY` left unset. + +For wanderer this is a hard constraint from the architecture, not a preference. +With node-local caches and registries and no clustering, two machines produce +two independent halves of the same map: trackers updating on one node, LiveView +sessions subscribed on the other, and updates that never meet. + +The same applies to wanderer-kills for the same underlying reason โ€” its cache is +node-local Cachex, so two machines would serve different answers depending on +which one a subscription landed on. Additionally, `auto_stop_machines` must be +off because a stopped machine drops the websocket. + +Both fly.toml files carry a comment saying so. + +Consequence: every wanderer deploy is a restart with a user-visible gap of +roughly 30-60s. This is acceptable โ€” map servers rehydrate from Postgres and +LiveView clients reconnect โ€” but it means recovery is "redeploy the previous +release", not a blue/green swap. + +### wanderer-kills: private-only, no public IP + +The kills app gets **no public IP address**. It is reachable only over 6PN at +`wanderer-kills.internal:4004`. + +Plain `ws://` is correct here and no certificate work is needed: 6PN is a +WireGuard mesh, encrypted at the network layer. Operator access for debugging is +via `fly ssh console` and `fly proxy`. + +Health checks run against its existing `/health` endpoint. + +### wanderer-kills: configuration upstreamed, not forked + +The `fly.toml` and the `BIND_IP` patch go upstream rather than being maintained +as a permanent divergence. This avoids carrying a second fork and rebasing it +indefinitely. + +Mechanically this is a pull request from the existing `guarzo/wanderer-kills` +fork, not a direct push: `gh` reports read access on +`wanderer-industries/wanderer-kills` for the operator's current token. A fork +used purely as a PR staging area is not a maintained fork, so the decision is +unchanged โ€” only the plumbing is. If the operator does in fact hold write access +(the token may simply be scoped narrowly, or the permission may come through a +team that the API does not surface here), a direct branch works equally well and +the pull request steps still apply. + +Consequence: the upstreamed `fly.toml` must be **operator-agnostic**. It cannot +hard-code an app name; `app` is overridden at deploy time. + +The same reasoning applies to the transport fix in *this* repository. It is a +strict improvement for any IPv6-only or Fly deployment and is not zoo-specific, +so it should go upstream rather than living in `guarzo/zoo`. + +The deployment guide reaches `wanderer-industries/community-edition` the same +way, via `guarzo/community-edition`. + +### Cutover ordering: both services in the same window + +Considered: kills first as a low-risk rehearsal; both together; kills after the +wanderer cutover (the original plan before kills was in scope). + +Chosen: **both in the same window**. The private 6PN link is therefore live from +day one, with no temporary public exposure of the kills service, and Phase 1 +staging validates the exact production topology rather than an interim one. + +The tradeoff accepted: this stacks a previously-unexercised build and deploy +path on top of the database cutover, and it makes the transport fix blocking for +cutover rather than deferrable. + +That tradeoff is substantially mitigated by the one-directional dependency. +**The kills app can be deployed and left running to warm its cache hours before +the window opens**, because nothing consumes it until wanderer points at it. The +"same window" constraint applies only to the traffic switch, not to the deploy, +so this costs the outage window almost nothing. + +### Sizing + +Wanderer: `shared-cpu-2x`, 2 GB RAM, existing `swap_size_mb = 512`. Sized for a +private-corp instance with headroom; `fly scale vm` covers growth. + +wanderer-kills: start at 2 GB. **This figure is a starting point, not a measured +one.** The service ingests EVE-wide killmails from RedisQ with a 24-hour TTL +(`lib/wanderer_app/kills/config.ex:38-40`), and the resident set could plausibly range +from a few hundred MB to over 1 GB. Phase 0 includes a measurement step. + +## Target architecture + +``` + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + users โ”€โ”€TLSโ”€โ”€โ–บโ”‚ Fly edge (Anycast + cert) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ :8080 + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ app: wanderer (iad) โ”‚ + โ”‚ EXACTLY ONE machine โ€” shared-cpu-2x, 2 GB, swap 512 โ”‚ + โ”‚ auto_stop_machines = off, min_machines_running = 1 โ”‚ + โ””โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ 6PN ws:// โ”‚ public wss/https โ”‚ DATABASE_URL (6PN) + โ–ผ โ–ผ โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” wanderer-notifier Fly Managed Postgres (iad) + โ”‚ app: wanderer-kills โ”‚ (still on the VM, PostgreSQL >= 15 + โ”‚ (iad) NO PUBLIC IP โ”‚ out of scope) + โ”‚ ONE machine, 4004 โ”‚ + โ”‚ stateless โ€” no DB โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +The kills link above assumes Option A (direct 6PN). Under Option B it is +`ws://.flycast:4004` through the Fly proxy instead; either way it is +private, IPv6, and carries no public IP. + +Retired: Caddy, the VM's docker-compose wanderer and wanderer-kills containers, +and the host Postgres. The `WEB_EXTERNAL_SCHEME` / `HTTPS_PORT` / `/certs/*` branch +in `config/runtime.exs:429-443` becomes dead config on Fly but is left in place, +as it is upstream-shared code. + +## Code changes required + +### Blocking + +1. **Custom domain support in `config/runtime.exs`.** Lines 16-38 currently + hard-code `https://.fly.dev` whenever `FLY_APP_NAME` is set โ€” + which is always, on Fly โ€” making `WEB_APP_URL` and `PHX_HOST` unreachable + there. Both are gated on the same `app_name == "NOT_FLY_APP"` test: line 20 + reads `PHX_HOST` only on the true branch, and line 36 reads `WEB_APP_URL` + only on the true branch. On Fly both fall to the else branch, so line 21 + forces the host to `"#{app_name}.fly.dev"` and line 37 forces + `"https://#{host}"`, and **neither environment variable is read at all.** + This forces the EVE OAuth `callback_url` + (line 268) to the `.fly.dev` host, so neither the staging subdomain nor the + production hostname can work. Change the `.fly.dev` derivation from an + override into a fallback: prefer an explicitly-set `WEB_APP_URL`, else an + explicitly-set `PHX_HOST`, else derive from `FLY_APP_NAME`. Behaviour is + unchanged when neither is set, which keeps the diff rebase-friendly against + upstream. + +2. **A websocket transport that forwards socket options.** Without this, the + kills websocket cannot reach `wanderer-kills.internal` at all โ€” see the + evidence section above. Add a thin transport module that reuses upstream's + callbacks but widens the option split to include `socket_opts`: + + ```elixir + defmodule WandererApp.Kills.Transport.WebSocketClient do + @behaviour Phoenix.Channels.GenSocketClient.Transport + @upstream Phoenix.Channels.GenSocketClient.Transport.WebSocketClient + @ws_opts [:extra_headers, :ssl_verify, :socket_opts] + + def start_link(url, transport_options) do + {ws_opts, rest} = Keyword.split(transport_options, @ws_opts) + url |> to_charlist() |> :websocket_client.start_link(@upstream, [self(), rest], ws_opts) + end + + defdelegate push(pid, frame), to: @upstream + end + ``` + + Delegating the `:websocket_client` callbacks to `@upstream` is sound because + its `init/1` expects exactly `[socket, transport_options]` + (`web_socket_client.ex:48`). Point `client.ex:502` at this module and pass + `socket_opts: [:inet6]`. + + Gate it on a new `WANDERER_KILLS_IPV6` variable, following the existing + `ECTO_IPV6` precedent at `runtime.exs:389-392`, defaulting to `false` so + non-Fly deployments are unaffected. + + **Known coupling:** this depends on an upstream private contract (the shape + of the handler-state argument). Mitigations: pin the + `phoenix_gen_socket_client` version, and submit the one-word fix + (adding `:socket_opts` to `@websocket_client_opts`) upstream so the shim can + eventually be deleted. The alternative โ€” copying the ~100-line module + outright โ€” trades this coupling for a larger permanent diff and was rejected. + + Note what this does **not** fix. Only `socket_opts` reaches + `:websocket_client`; `timeout` and `tcp_opts` remain handler state, and + upstream's `init/1` reads only `:keepalive` from it + (`web_socket_client.ex:48-51`). `websocket_client` 1.5.0 also hardcodes its + connect timeout to 6000 ms (`websocket_client.erl:276`), so no option would + change it. The `connect_timeout` / `send_timeout` / `recv_timeout` values in + `client.ex` are dead before this change and dead after it โ€” they should be + removed rather than left looking adjustable. + +3. **A 6PN-reachable listener on wanderer-kills.** The client fix above is + necessary but **not sufficient**: the service binds `{0, 0, 0, 0}` and so has + no IPv6 listener for a 6PN address to reach. Two ways to close this, and the + choice is open โ€” see "Open item: 6PN reachability" below. + + **Option A โ€” configurable bind, upstreamed (recommended).** Add an `ip:` + override to upstream's `runtime.exs`, read from an environment variable and + **defaulting to `{0, 0, 0, 0}` so existing docker-compose deployments are + unaffected**. Set it to `::` (or the `fly-local-6pn` address) on Fly. This + mirrors the backward-compatible pattern used for `WANDERER_KILLS_IPV6` in + wanderer, and it is the change that makes the service Fly-deployable for + every operator, not just this one. Cost: it is a second upstream change that + must be merged and released before cutover. + + **Option B โ€” Flycast, no upstream change.** Reach the service at + `.flycast` through the Fly proxy instead of addressing the machine + directly over 6PN. The proxy terminates the connection and forwards to the + app's `internal_port` locally, so the existing `{0, 0, 0, 0}` bind keeps + working. Requires allocating a private IPv6 address and defining + `[[services]]`. Cost: an extra proxy hop, and it makes the fly.toml carry a + service definition that Option A would not need. + + **The client-side `:inet6` change in blocking item 2 is required either + way** โ€” both the 6PN address and the Flycast address are IPv6. + +### Non-blocking + +4. **Health endpoint, on a dedicated pipeline.** Add a health route returning + 200 with app version and database reachability. Fly's health checks need a + path; a TCP check would only prove the listener is up, not that the app can + reach Postgres. + + **It must not use the existing empty scope at `router.ex:377-379`.** That + scope is `pipe_through [:api]`, and the `:api` pipeline (`router.ex:171-175`) + includes `WandererAppWeb.Plugs.CheckApiDisabled`, which halts with `403` when + `WandererApp.Env.public_api_disabled?/0` is true. Chaining machine liveness + to a product feature flag means that setting + `WANDERER_PUBLIC_API_DISABLED=true` would make Fly consider the **sole** + machine unhealthy and kill it โ€” turning a config toggle into a total outage. + + Note the failure is latent, not immediate: `WANDERER_PUBLIC_API_DISABLED` + defaults to `"false"` (`runtime.exs:57-59`), so this works on day one and + breaks catastrophically much later, which is the worse shape of bug. + + Add a dedicated `:health` pipeline containing only `plug :accepts, ["json"]`, + with no feature-flag plugs, and scope the route through that. Keep it free of + authentication and rate limiting for the same reason. + +5. **`fly.toml` rewrite (wanderer).** Real app name; `primary_region` `ams` -> + `iad`; `[[vm]] size` `shared-cpu-1x` -> `shared-cpu-2x` with + `memory = '2gb'`; drop the hard-coded `PHX_HOST = 'wanderer-test.fly.dev'`; + add `min_machines_running = 1` with the single-machine comment; add the + health check. Keep `release_command = '/app/bin/migrate.sh'`. + +6. **Remove the `[[metrics]]` block** (`fly.toml:37-40`). It scrapes + `:4021/metrics`, but `PROMEX_DISABLED` defaults to `"true"` + (`runtime.exs:457-459`), so nothing listens and every scrape fails. Metrics + are out of scope; the block is two lines to restore later. + +7. **New `fly.toml` (wanderer-kills, upstream).** Generic app name, + `internal_port = 4004`, one machine with `auto_stop_machines = off` and + `min_machines_running = 1`, and a health check against `/health`. + + Under Option A (direct 6PN) no public IP is allocated and the health check + should use Fly's **top-level `[checks]` section**, which does not require a + public service definition. Under Option B (Flycast) a `[[services]]` block is + required regardless. Confirm the exact TOML against current Fly documentation + at implementation time rather than assuming it from this document. + +Unchanged: secrets handling, the Dockerfile, and the Repo configuration. + +## Migration and cutover + +### Phase 0 โ€” measure and prepare (no user impact) + +- Measure the database: + `SELECT pg_size_pretty(pg_database_size(current_database()))`, plus a timed + rehearsal `pg_dump` / `pg_restore`. That timing *is* the outage window. +- Measure the kills service's resident memory on the VM + (`docker stats wanderer-kills` over a representative period) to confirm or + correct the 2 GB starting figure. +- Create both Fly apps and the MPG instance (PostgreSQL >= 15) in `iad`. +- Set all wanderer secrets: `SECRET_KEY_BASE`, `EVE_CLIENT_ID` / + `EVE_CLIENT_SECRET` and any additional EVE client pairs in use, + `WANDERER_ADMIN_PASSWORD`, `WEB_APP_URL`, plus whichever feature flags the VM + currently sets. Include the three that are new or newly load-bearing: + - `WANDERER_KILLS_SERVICE_ENABLED=true` โ€” **it defaults to `false`, so + omitting it silently disables kills with no error.** + - `WANDERER_KILLS_BASE_URL=ws://wanderer-kills.internal:4004` + - `WANDERER_KILLS_IPV6=true` + +**Open item to confirm before Phase 1 โ€” EVE SSO callbacks.** The EVE developer +portal is believed to allow a single callback URL per application. If so, the +staging subdomain and production hostname cannot share one EVE app, and a +**second EVE application** is needed for the staging period, with its own client +ID and secret set on the Fly app while it serves the staging subdomain โ€” then +swapped back to the production EVE credentials at cutover step 6. If the portal +does allow multiple callback URLs, this reduces to adding one URL and no swap is +needed. The operator is to confirm; the rest of the plan is unaffected either +way. + +Note this open item is no longer purely about hostnames: the staging-safe +configuration above depends on a **separate EVE application** so that staging +never refreshes a production character's token. If the portal turns out to allow +multiple callback URLs on one application, a second application is still wanted +for credential isolation. + +**Open item: 6PN reachability โ€” Option A or Option B.** Blocking change 3 offers +a configurable upstream bind (A) or Flycast (B). A is recommended and is the +better fix for every operator, but it depends on an upstream pull request being +merged and released before the cutover window. If that timeline is not +comfortable, B works today with no upstream change. Decide before Phase 1, since +it determines the shape of the kills `fly.toml`. + +### Phase 1 โ€” staging validation + +Deploy the kills app first and leave it running; nothing consumes it yet. +Restore a throwaway copy of production data into MPG, deploy wanderer, point the +staging subdomain at Fly, and run the verification gates below. This copy is for +validation only and is discarded at cutover. + +**This is one Fly app per service throughout, not two.** The wanderer app first +serves the staging subdomain, then has the production hostname added and the +staging one removed. The single kills app serves both periods โ€” being stateless +and private, it needs no staging duplicate. + +#### Staging-safe configuration (mandatory) + +Staging runs a copy of production data **while production is still live**. That +data carries live credentials and live outbound integrations, so an unmodified +staging instance will reach into production's world. Configure the following +before the first boot against restored data, not after. + +- **EVE refresh tokens are the sharpest edge.** `refresh_token/1` + (`lib/wanderer_app/esi/api_client.ex:746`) reads a character's refresh token + and persists the rotated result back via `WandererApp.Api.Character.update`. + Because EVE rotates refresh tokens, a staging instance refreshing a character + that production also tracks **invalidates production's token** โ€” logging real + users out of the live map. Use the separate staging EVE application (see the + open item below) and do not track production characters from staging. +- **Disable outbound dispatchers.** `lib/wanderer_app/external_events/` contains + `webhook_dispatcher.ex` and `discord_dispatcher.ex`; the restored dump carries + `map_webhook_subscription`, `map_discord_webhook`, and + `map_discord_notification` rows pointing at real endpoints. Left enabled, + staging duplicates every notification your users receive. Turn the external + events services off, and additionally **scrub the destination rows in the + restored copy** so a misconfiguration cannot leak โ€” belt and braces, because + the blast radius is other people's Discord servers. +- Copy production feature flags only after auditing them for outbound effects. + "Copy whatever the VM sets" is not safe as a blanket instruction. + +This is the one place the plan deliberately does not validate production +behaviour faithfully. The tradeoff is accepted: a staging instance that mails +real users is worse than one that proves slightly less. + +### Phase 2 โ€” production cutover (planned outage) + +Ordered so that nothing writes to two databases at once: + +1. Lower the DNS TTL on the production hostname **at least a day ahead**, and + **pre-provision the production certificate** (`fly certs add` with the DNS-01 + challenge, gated on `fly certs check` reporting Ready). Issuing inside the + window would make an ACME delay into downtime. +2. Confirm the kills app is up and its cache warm. This is done *before* the + window, not inside it. +3. Announce the window. Stop wanderer on the VM + (`docker compose stop wanderer`). Writes cease here, which is what makes the + dump consistent. Leave Postgres and the VM's kills container running. +4. **Scale the Fly wanderer app to zero and confirm no machine is running.** + This step is mandatory and easy to overlook: after Phase 1 the Fly app is + *live* against MPG, and its tracker pools write character locations every + 10-30s with no user interaction at all. Restoring into a database that still + has an application attached risks `pg_restore` conflicts and, worse, silently + interleaving staging-era background writes into the restored production data. + The app stays stopped through steps 5, 6 and 7. +5. `pg_dump -Fc` the live VM database, then restore into MPG, replacing the + staging copy. +6. Point the Fly app at the production hostname (set `WEB_APP_URL`; the + certificate already exists from step 1); update the EVE callback if a second + app was used for staging. Swap the staging-safe configuration from Phase 1 + back to production values โ€” re-enable outbound dispatchers and restore the + real EVE credentials. +7. **Run the migrations against the restored database.** The restore in step 5 + rolled MPG's schema back to the VM's version, discarding whatever Phase 1's + deploy migrated forward. `fly scale count` does **not** run + `[deploy].release_command` โ€” Fly executes that only during a deploy โ€” so + scaling up would boot the new release against an outdated schema. Run + `/app/bin/migrate.sh` explicitly in a one-off machine, or perform a + controlled `fly deploy`. This does not affect rollback: it alters the MPG + copy, not the VM's Postgres. +8. **Start the Fly app** with the production configuration now in place. +9. Flip DNS to Fly. Verify login, map load, tracking, and kill data against real + data. +10. Only then stop the VM's kills container and the rest of the VM stack. + +**wanderer-notifier during cutover:** it stays on the VM, reachable over the +public internet, and is migrated as separate work afterwards. + +### Rollback + +While the VM's Postgres is still the newer copy, rollback is "start wanderer on +the VM, flip DNS back." + +**Rolling back requires restarting both VM containers, not just wanderer.** +Because the Fly kills app is private-only, a VM-resident wanderer cannot reach +it โ€” so the VM's own kills container must come back up too. Keep the entire VM +stack intact but stopped, not just the wanderer service. + +**The point of no return is the first background write on Fly, not the first +user write and not the DNS switch.** Tracker pools write character locations +every 10-30s from the moment the app boots, with no user interaction required, +so the window in which rollback is lossless closes within seconds of starting +the app in step 8 โ€” before any user has logged in. Treat step 8, not step 9, as +the commit point. After it, rolling back loses whatever was written since. + +Step 7's migrations are not part of this: they alter the MPG copy, not the VM's +Postgres, so rolling back after migrating but before starting is still lossless. + +Keep the VM intact but stopped for roughly a week after cutover. + +## Verification + +All eight gates must pass on staging before Phase 2, and again on production +data after cutover. + +1. **EVE OAuth round-trip** โ€” log in with a real character and get redirected + back. Most likely thing to break: it depends on the `runtime.exs` change, the + `WEB_APP_URL` secret, and the EVE callback all agreeing. +2. **Map loads with real data** โ€” systems, connections, and signatures render + from the restored dump. Validates dump/restore, not just connectivity. +3. **Character tracking writes** โ€” a tracked character's location updates. + Exercises ESI egress from Fly, token refresh, tracker pools, and DB writes. + **On staging this must use a dedicated test character**, registered against + the staging EVE application and not tracked by production. Using a real + user's character here would rotate their refresh token and log them out of + the live map โ€” see the staging-safe configuration above. +4. **Real-time updates arrive** โ€” a change appears without a refresh. Proves the + PubSub -> LiveView path survived. +5. **Kills websocket reaches `connected` over 6PN** โ€” check + `WandererApp.Kills.get_status/0`. This is the gate that proves the transport + fix; it is the single most likely thing to fail, and it fails *silently* + (see risks). Confirm explicitly rather than inferring from the absence of + errors. +6. **Killmails render in the map UI** โ€” kill data appears on a system with + recent activity. Proves the full path: subscription, ingest, storage, and + broadcast, not merely that a socket opened. +7. **Release migrations ran clean** โ€” `interweave_migrate` completed with no + pending migrations. +8. **Restart survivability** โ€” `fly machine restart` on both apps, then confirm + the map rehydrates from Postgres and the kills client reconnects. A deploy + rehearsal. + +Post-cutover, additionally confirm the notifier still delivers. + +## Residual risks + +- **Machine liveness must stay decoupled from product configuration.** The + health-route decision above exists because the obvious placement would let + `WANDERER_PUBLIC_API_DISABLED` kill the only machine. The general rule holds + beyond that one flag: with `min_machines_running = 1` and no redundancy, any + feature flag reachable from the health path is an outage waiting to happen. + Re-check this whenever the health route or the `:health` pipeline changes. +- **Staging isolation is enforced by configuration, not by architecture.** + Nothing in the code prevents a staging instance from mailing production + Discord webhooks or rotating production EVE tokens โ€” only the Phase 1 + checklist does. A missed step there has effects on real users that the + migration's own rollback plan cannot undo. + +- **Kills failure is silent and self-limiting.** After `@max_retries 10` the + client stops retrying automatically and falls back to a 15-minute health-check + cycle. A prolonged kills outage therefore presents as "kill data quietly + stopped", not as an error or an alarm. Worth a monitoring follow-up, which is + out of scope here. +- **The transport shim couples to an upstream private contract.** Mitigated by + pinning the dependency version and upstreaming the proper fix. A + `phoenix_gen_socket_client` upgrade should re-verify the handler-state shape. +- **Kills memory sizing is unmeasured.** 2 GB is a starting estimate; Phase 0 + measures it. Under-sizing presents as OOM restarts, which the one-directional + dependency makes non-fatal to wanderer but which do drop kill data. +- **Deploys are user-visible** (~30-60s). Inherent to single-machine plus + in-memory map state. Fixing it means making map state cluster-aware, a far + larger project. +- **ESI rate limiting is per-source-IP.** Moving to Fly changes the egress IP, + and now *two* services call ESI from the same Fly organisation. Not expected + to matter at private-corp scale, but it is a changed variable and the kills + service is the heavier ESI consumer of the two. +- **`runtime.exs` host handling is upstream-shared code**, so the change is a + rebase-conflict candidate. Mitigated by keeping it minimal and + backward-compatible. diff --git a/docs/superpowers/specs/2026-08-07-deploy-approval-gate-design.md b/docs/superpowers/specs/2026-08-07-deploy-approval-gate-design.md new file mode 100644 index 000000000..409a88aa1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-deploy-approval-gate-design.md @@ -0,0 +1,453 @@ +# Deploy Approval Gate โ€” Design + +**Date:** 2026-08-07 +**Status:** Approved (brainstorming session) +**Replaces:** Fly GitHub integration auto-deploying on push to `guarzo/release` +**Related:** `docs/superpowers/specs/2026-08-02-flyio-migration-design.md`, `ce58765b` (ci: drop the VM release pipeline) + +## Amendment โ€” 2026-08-07, during implementation + +**`guarzo/release` is retired, not retained as a CI-owned bookmark.** Everything +below describing the workflow force-pushing that branch as its final step no +longer holds. The workflow deploys and tags; the newest `v20*` tag is the sole +record of what is in production, and `guarzo/release` is frozen by a ruleset with +an empty bypass list. + +Two things forced the change: + +1. **The Actions bypass actor is unavailable on user-owned repositories.** + `guarzo/wanderer` is owned by a user, not an org. Creating the ruleset with + `{"actor_type": "Integration", "actor_id": 15368}` returns HTTP 422: + *"Actor GitHub Actions integration must be part of the ruleset source or + owner organization."* This invalidates the section *Permissions and the + `guarzo/release` ruleset* below, whose open question โ€” whether a bypass actor + can force-push โ€” turns out to be unanswerable as posed. A `DeployKey` bypass + actor **is** accepted (verified against a disposable probe ruleset), but it + costs a long-lived SSH key with write access to every branch, stored beside + the Fly deploy token. +2. **Given that, the bookmark was not worth its own credential.** Its only + consumers were `git diff guarzo/release..guarzo/zoo` and human reassurance โ€” + both of which the tag already serves. Retiring it removes a ref that is + authoritative-looking but only conditionally accurate, and removes the last + thing in the design that needed write access beyond `GITHUB_TOKEN`. + +**What this costs:** `git diff guarzo/release..guarzo/zoo` is replaced by +`git diff "$(git tag -l 'v20*' | sort | tail -1)"..guarzo/zoo`. + +**What it does not cost:** tag reachability across rebases โ€” the original reason +tags matter โ€” was always the tag's job, never the branch's. The section *A failed +deploy produces no tag and does not move the bookmark* still holds with the +bookmark clause dropped; the partial-failure window narrows from three steps to +two. + +The runbook in `docs/ZOO-FORK.md` and the plan at +`docs/superpowers/plans/2026-08-07-deploy-approval-gate.md` reflect the amended +design. Where they disagree with the text below, they govern. + +## Problem + +Two problems, discovered together. + +**1. Promotion is a silent manual step.** Work merges to `guarzo/zoo`. Production +only moves when `guarzo/release` is hard-reset to `guarzo/zoo` and pushed, which +Fly's GitHub integration watches. Forgetting the reset produces no signal +anywhere โ€” the merge succeeds, CI is green, and production quietly keeps serving +the old commit until someone notices a shipped change missing. + +**2. Deploy tags stopped being created.** `.github/workflows/release.yml` created +a `v$(date +%Y%m%d%H%M%S)` tag on every promotion. It was deleted on 2026-08-05 +in `ce58765b` along with the rest of the VM release pipeline, because it also +built and SSH-deployed to a host that no longer serves traffic. Deleting it +removed the tagging as collateral damage. + +Evidence the tagging is gone: + +| Event | Timestamp (UTC) | +|---|---| +| Last tag created (`v20260805165448`) | 2026-08-05 16:54 | +| `release.yml` deleted (`ce58765b`) | 2026-08-05 17:09 | +| Fly release v9 | 2026-08-07 18:02 โ€” no tag | +| Fly release v10 | 2026-08-07 18:17 โ€” no tag | + +This matters more on this fork than it would elsewhere. `guarzo/zoo` is +regularly rebased onto upstream and has commits squashed, so a deployed commit +with nothing pointing at it becomes unreachable. Since 2026-08-05 the only +record of what is in production has been a Fly image ref +(`registry.fly.io/wanderer:deployment-`), which is not checkout-able and +does not survive a rewrite. + +## Goal + +Make promotion to production a **deliberate, single-click decision** that cannot +be silently forgotten, and restore an immutable tag on every deploy โ€” where the +tag means *"this commit served production traffic"*, not merely *"this commit +was promoted"*. + +Explicitly **not** a goal: continuous deployment. "Deploy is a decision" is a +desired property, not a limitation to remove. + +## Decisions (from brainstorming) + +| Decision | Choice | +|---|---| +| Trigger | A **successful `Test Suite` run** on `guarzo/zoo` opens a deploy run that waits for approval (`workflow_run`, not `push`) | +| Gate | GitHub Environment `production-deploy` with required reviewer | +| Who talks to Fly | The workflow, via `flyctl deploy` with a scoped token | +| Fly GitHub integration | Disconnected โ€” replaced, not run alongside | +| Tag timing | **After** a verified-healthy deploy, never before | +| `guarzo/release` | Retained as a CI-owned bookmark of what is in production | +| Concurrency | One non-cancellable deploy job (`cancel-in-progress: false`); stale approvals are neutralized by a post-approval staleness guard, not by cancellation | +| Deploy credential | Stored as an **environment** secret on `production-deploy`, not a repository secret | +| Rollback | `workflow_dispatch` with a `ref` input (tag or SHA) | + +### Rejected alternatives + +- **Gate promotes `guarzo/release`, Fly's webhook still deploys.** Smaller + change, but the workflow hands off to a webhook and never learns whether the + deploy succeeded โ€” so the tag would still only mean "approved". It also + depends on Fly's integration firing on a force-push, which is unverified and + becomes necessary the moment `guarzo/zoo` is rebased. +- **`workflow_dispatch`-only, no push trigger.** Simplest, but does not solve + problem 1: it remains an action that can be forgotten. The pending-approval + run appearing after every merge is precisely what makes this work. +- **Auto-deploy every merge to `guarzo/zoo`.** Removes the failure mode + entirely, but discards the "deploy is a decision" property, which is wanted. +- **`on: push` trigger with the test suite as a stated precondition.** This was + the first draft of this design. It does not work: an environment gate does not + wait on another workflow's checks, so the approval prompt appears while the + suite is still running, and a red commit can be approved and shipped. Replaced + by the `workflow_run` trigger. +- **A single concurrency group with `cancel-in-progress: true`.** Also from the + first draft. It correctly coalesces pending approvals but also cancels a run + that is mid-deploy, which changes production without tagging or bookmarking + it. Replaced by a non-cancellable job plus a staleness guard. +- **Two jobs: a gate job and a separate deploy job.** Proposed while applying + the review findings, then rejected: a protected environment gates every job + referencing it (two approval prompts), and the environment-scoped + `FLY_API_TOKEN` cannot be read by a job outside the environment or passed in + from one. Replaced by a single gated job. + +## Architecture + +One new workflow, `.github/workflows/zoo-deploy.yml`. The deleted `release.yml` +(recoverable at `ce58765b^`) is the starting point โ€” it already carries the +tag-and-push logic, SHA-pinned actions, and a concurrency group. + +### Trigger and gate + +The deploy workflow is **not** triggered by the push directly. It is triggered by +the test workflow finishing successfully on `guarzo/zoo`: + +```yaml +on: + workflow_run: + workflows: ["๐Ÿงช Test Suite"] + types: [completed] + branches: [guarzo/zoo] + workflow_dispatch: + inputs: + ref: + description: 'Tag or SHA to deploy (defaults to guarzo/zoo HEAD)' + required: false +``` + +with `if: github.event.workflow_run.conclusion == 'success'` on the first job, +and the deployed commit taken from `github.event.workflow_run.head_sha` rather +than from `github.ref`. + +**Why not `on: push`.** An environment gate does not wait for another workflow's +checks. `Test Suite` is a separate workflow (`.github/workflows/test.yml`) whose +`gate` job is named `Test Suite` (`test.yml:334-335`) and is wired to the +`guarzo/zoo` ruleset for pull requests โ€” nothing connects it to a push-triggered +deploy run. On a plain `push` trigger the approval prompt would appear +immediately, and a commit whose tests were still running, or already red, could +be approved and shipped. `workflow_run` makes the dependency real: a red suite +can never reach the approval prompt. + +`workflow_run` evaluates its workflow file from the default branch, which is +`guarzo/zoo` โ€” the branch being deployed โ€” so this trigger works without extra +configuration. + +**Failure and cancellation of the test run** are handled by the job's `if:` +condition: any conclusion other than `success` (`failure`, `cancelled`, +`timed_out`, `skipped`) skips the job. + +Note precisely what this does and does not do. `workflow_run` offers **no +conclusion filter on the trigger itself**, so GitHub creates a deploy run for +*every* completion of the test suite, red or green. The `if:` condition operates +one level down: it skips the single job, so no environment is referenced, no +approval is requested, and the deploy credential is never released. + +The observable consequence is that the Actions tab accumulates **skipped** deploy +runs after failed suites. That is the mechanism working. The invariant is "a red +commit cannot reach the approval prompt", not "a red commit produces no run" โ€” +the latter is not achievable with this trigger, and any validation written +against it would fail on a correct implementation. + +### Concurrency: one non-cancellable job, with a staleness guard + +A **single** `deploy` job carries both the environment gate and all the work, +with job-level `concurrency: {group: zoo-deploy-run, cancel-in-progress: false}`. + +**Why not split gate and deploy into two jobs.** Two reasons compound: + +1. A protected environment gates **every job that references it**, so a + `await-approval` job plus a `deploy` job produces two approval prompts per + release. +2. `FLY_API_TOKEN` is an environment secret (see Rollout), readable only by a + job declaring `environment: production-deploy`. The deploy job must therefore + reference the environment โ€” it cannot delegate the gate to a predecessor. + Secrets cannot be passed between jobs via outputs. + +The approval-scoped credential is what forces the deploy work inside the gated +job. Accepting two prompts to keep a tidy job graph is the wrong trade. + +**Why `cancel-in-progress: false`.** GitHub's cancellation applies to *running* +jobs, not only to runs waiting on approval. A merge landing mid-`flyctl deploy` +would cancel the workflow while Fly's builder continues remotely โ€” production +changes, and the tag and bookmark steps never run. That is precisely the failure +this design exists to prevent, reintroduced by the mechanism meant to tidy the +approval queue. `false` also serializes deploys: a second approved run queues +behind the first rather than racing it onto the single machine. + +**Staleness guard replaces cancellation.** Because runs are never cancelled, +approving an old pending run would otherwise deploy a superseded commit. The +first step after approval compares the resolved SHA against the current +`origin/guarzo/zoo` tip and **exits successfully without deploying** when they +differ. Approving a stale run is then a no-op rather than a regression. + +The guard applies to `workflow_run` runs only. Under `workflow_dispatch` it is +skipped entirely โ€” deploying a ref that is *not* the branch tip is exactly what +rollback is for. + +**Accepted cost:** stale pending approvals accumulate in the Actions tab instead +of auto-cancelling, and are dismissed manually. In exchange: one approval per +deploy, and no cancellation path into a running deploy. + + +### The approval gate + +**`environment: production-deploy`** on the deploying job. Nothing runs until +approved. The repository is public, so environment protection rules are +available at no cost. + +**Pending runs expire after 30 days** and are then marked failed. This is a real +bound, not "wait as long as you like". It is acceptable rather than mitigated: a +run that has sat unapproved for 30 days is itself a signal, and any subsequent +push to `guarzo/zoo` opens a fresh run against a newer SHA. No expiry-renewal +mechanism is specified, deliberately. + +**Self-approval must be confirmed, not assumed.** GitHub is understood to permit +a reviewer to approve a run they triggered, but with a single required reviewer +this is load-bearing โ€” if it does not hold, the gate is unopenable. Verify +before relying on it (see Validation). + + +### Post-approval sequence + +Strictly linear; each step runs only if the previous one succeeded. That +ordering is what makes the tag trustworthy. + +1. **Checkout the approved SHA** โ€” `github.event.workflow_run.head_sha` for + `workflow_run` runs, or the `ref` input for `workflow_dispatch` โ€” with + `fetch-depth: 0` (annotated tagging needs full history). Never `github.ref`: + under `workflow_run` that resolves to the default branch's tip at trigger + time, not the tested commit. +2. **`flyctl deploy --app wanderer`** authenticated with the `FLY_API_TOKEN` + environment secret. Fly runs `release_command` first (`fly.toml:29` โ€” + migrations against `DIRECT_DATABASE_URL`), so a failed migration fails the + deploy before new code serves traffic. +3. **Wait for healthy.** Under `strategy = 'rolling'` (`fly.toml:30`), + `flyctl deploy` blocks on the `/health` check (`fly.toml:84-88`; + route at `lib/wanderer_app_web/router.ex:391`). An unhealthy machine fails + the step. +4. **Tag the SHA** โ€” `v$(date +%Y%m%d%H%M%S)`, annotated, message recording the + short SHA and the Fly release version. Push the tag. +5. **Move `guarzo/release`** to that SHA. This is a force-push: a `guarzo/zoo` + rebase makes the update non-fast-forward, and the branch is a mirror by + definition. + +### Failure semantics + +A failed deploy produces no tag and does not move the bookmark. `guarzo/release` +and the newest tag both continue to point at the last commit that actually +served traffic. + +This is a real improvement over the current process, where `guarzo/release` is +moved *before* Fly attempts anything โ€” so a failed deploy leaves the branch +asserting a success that never happened. + +#### Partial failure after a successful deploy + +Steps 2โ€“5 are **not atomic**. If the deploy succeeds and step 4 or 5 then fails, +production has moved while the tag or bookmark has not โ€” the one state that +contradicts the invariant this design is built on. It cannot be prevented, only +made recoverable and loud: + +- **Bounded blast radius.** Only the tag/bookmark steps can fail this way. They + are pure git operations against a known SHA, with no dependency on Fly. +- **Idempotent recovery.** Re-running the workflow via `workflow_dispatch` with + `ref` set to the deployed SHA must converge rather than error. The tag name is + generated from the clock, so a re-run would otherwise mint a *second* tag for + the same commit. The tag step therefore looks for an existing `v*` tag + pointing at HEAD and reuses it if present, creating a new one only when the + commit is genuinely untagged. The bookmark force-push is idempotent by + construction. +- **Loud, not silent.** A failure here fails the run, so the red run is the + signal. The recovery is the `workflow_dispatch` re-run above; it redeploys the + same SHA, which on this app costs one restart. + +#### Permissions and the `guarzo/release` ruleset + +The workflow needs `contents: write`, scoped to the deploy job only โ€” the +default token is read-only, and the approval-gate job has no reason to hold +write access. + +**The ruleset restricting pushes to `guarzo/release` will reject the workflow's +own push unless a bypass actor is configured for it.** This is the design's +sharpest self-inflicted failure mode: it surfaces at step 5, *after* a +successful production deploy, in exactly the partial-failure state described +above. The bypass must also permit **force-push** (non-fast-forward), since a +`guarzo/zoo` rebase guarantees the bookmark update is not a fast-forward. + +Both behaviors โ€” bypass actor and force-push permission โ€” are verified during +validation rather than assumed, because the cost of getting them wrong is paid +in production. + + +### `guarzo/release` becomes CI-owned + +It stops being the deploy trigger and becomes the answer to "what is in +production right now". A repository ruleset restricts direct pushes to the +workflow's actor, so the existing hard-reset-and-push habit is **refused** rather +than silently accepted and ignored โ€” the loud failure that was missing. A +ruleset already exists on `guarzo/zoo`, so this follows an established pattern. + +### Rollback + +`workflow_dispatch` with `ref: v20260805165448`, approved, deploys that exact +tag. This capability does not currently exist: since 2026-08-05 no deployed +commit has had a checkout-able reference, so a bad deploy following a +`guarzo/zoo` rebase could strand the previous good commit as an orphan. + +## Rollout + +Order matters. Fly's integration is disconnected **first** so that no window +exists in which both it and the workflow deploy โ€” on this app a deploy is a full +restart of the single machine with a user-visible gap (see the constraint +comment at the top of `fly.toml`), so a double-deploy is two outages for one +release. During the gap, `flyctl deploy` from a workstation remains a working +deploy path. + +Manual steps, in order: + +1. **Disconnect Fly's GitHub integration** for app `wanderer` (Fly dashboard โ†’ + app โ†’ Settings). +2. **Create environment `production-deploy`** with the repository owner as + required reviewer. +3. **Create a deploy-scoped token** โ€” `fly tokens create deploy -a wanderer` โ€” + stored as an **environment secret on `production-deploy`**, not a repository + secret. A repository secret is readable by any workflow running on a trusted + branch; an environment secret is released only to a job that has cleared the + approval gate. The design's whole premise is that nothing reaches production + without approval, and the credential is part of "nothing". Deploy-scoped + rather than a personal org token, so a compromised runner cannot reach + `kills`, `route-builder`, or other apps in the org. +4. **Add a ruleset on `guarzo/release`** restricting direct pushes, with a + bypass actor for the deploy workflow that permits force-push (see + *Permissions and the `guarzo/release` ruleset*). + +Then land `.github/workflows/zoo-deploy.yml`. + +Step 2 precedes step 3 because the environment must exist before a secret can be +attached to it. + +### Validation + +First run is a `workflow_dispatch` against current `guarzo/zoo` HEAD. Approve +it, then confirm: + +- **self-approval works** โ€” the required reviewer can approve a run they + triggered. If not, the gate is unopenable and a second reviewer or a different + protection rule is needed. Check this first; everything else is moot if it + fails. +- the Fly release counter increments, +- `/health` passes and the machine stays up, +- a `v2026...` tag exists pointing at the expected SHA, +- **`guarzo/release` has moved to that SHA** โ€” this is the check that proves the + ruleset bypass and force-push permission are configured correctly. It fails + *after* a successful deploy, so verifying it deliberately here is what keeps + it from being discovered during a real release. + +Then confirm the trigger itself, which `workflow_dispatch` does not exercise: +push a trivial commit to `guarzo/zoo` and verify that no deploy run exists while +the suite is still running, that one appears in state `waiting` once it goes +green, and that a commit whose suite went red produces a **skipped** deploy run +that never reaches `waiting`. + +Note also that **merging the workflow itself arms the automatic path**: +`test.yml` triggers on pushes to `guarzo/zoo` (`test.yml:6-7`), so the merge +commit's green suite opens a pending deploy run for the same SHA the validation +`workflow_dispatch` will target. The staleness guard cannot distinguish them โ€” +both are the branch tip โ€” so approving both would deploy one commit twice. The +automatic run is cancelled before validation begins. + +This costs one deliberate restart to prove the pipeline, which is preferable to +discovering a broken pipeline during a real change. + +### Reverting the whole design + +**Order matters here too, in reverse.** Disable or delete +`.github/workflows/zoo-deploy.yml` **first**, then reconnect Fly's GitHub +integration, then drop the `guarzo/release` ruleset. + +Reconnecting Fly while the workflow is still active recreates the double-deploy +this design removes, in a more confusing form: the workflow deploys, then pushes +`guarzo/release` as its final step, which triggers the reconnected integration +to deploy the same commit again โ€” two restarts, the second one apparently +uncaused. + +Once the workflow is gone, `guarzo/release` still tracks the same commits, so +the current process resumes unchanged. + +## Assumptions and unresolved risks + +- **Unverified: interaction between the existing `production` environment and + the new `production-deploy`.** A `production` environment exists (created + 2026-08-05, no protection rules), most likely by Fly's integration for its own + deployment records. Using a separate name is the mitigation; the interaction + itself has not been tested. +- **Unverified: whether disconnecting the GitHub integration affects anything + else on the Fly app.** It is understood to be a build trigger rather than + runtime configuration, but this has not been confirmed. +- **Unverified: that GitHub permits self-approval of a deployment run.** With a + single required reviewer this is load-bearing โ€” if it does not hold, the gate + cannot be opened at all. First item in the validation checklist. +- **Unverified: that a ruleset bypass actor can force-push to `guarzo/release`.** + Required by step 5, and failing there leaves production deployed but + unbookmarked. Also in the validation checklist. +- **Unverified: that a protected environment gates every job referencing it.** + The single-job design assumes it does. If one approval in fact covers all jobs + in a run, splitting gate and deploy becomes viable again โ€” but the + environment-scoped credential would still force the deploy work inside the + gated job, so the single-job shape stands either way. Low-stakes to confirm. +- **Assumed: `flyctl deploy` exits non-zero when the release fails its health + check.** Steps 4 and 5 depend on this. To be confirmed during validation; if + it does not hold, an explicit `flyctl status` / `/health` poll is needed + before tagging. +- **Accepted: steps 2โ€“5 are not atomic.** See *Partial failure after a + successful deploy*. Recovery is a `workflow_dispatch` re-run, at the cost of + one restart. +- **Accepted: pending approvals expire after 30 days.** No renewal mechanism. +- **Not addressed: migration rollback.** `release_command` runs migrations + forward. Deploying an older tag does not revert a schema change, so a + destructive migration is still a manual recovery. Unchanged from today. + +## Out of scope + +- Staging or preview environments. +- Multi-machine or blue/green deploys โ€” `fly.toml` documents the single-machine + constraint, and lifting it requires cluster-aware map state. +- Changes to `test.yml` beyond consuming its result as a precondition. +- Upstream (`wanderer-industries`) workflows: `build.yml`, `build-develop.yml`, + `advanced-test.yml`, `release_actions.yml` are untouched. diff --git a/docs/superpowers/specs/2026-08-07-discord-route-alerts-design.md b/docs/superpowers/specs/2026-08-07-discord-route-alerts-design.md new file mode 100644 index 000000000..737941ace --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-discord-route-alerts-design.md @@ -0,0 +1,624 @@ +# Discord route alerts โ€” design + +Date: 2026-08-07 +Status: approved for planning + +## Intent + +Post a Discord message when a change to a map's topology opens a short, +highsec-only route from the map's home system to Jita. + +The user request: "notify when new systems are added to the map that make a +route from the home system to Jita in less than 6 jumps (that don't include +null-sec or low-sec)." + +This is a sibling of the existing kill notification feature, reusing its +configuration table, its per-webhook delivery queue, and its embed formatter. + +## Repository evidence + +**Branch dependency.** Every claim below about the Discord stack +(`DiscordDispatcher`, `Discord.Router`, `Discord.WorkerSupervisor`, +`MapDiscordWebhook`, `MapDiscordNotification`) was inspected on **`guarzo/zoo`**, +where the kill-notification feature lives. None of it exists on `origin/main`. +This feature must be built on a branch derived from `guarzo/zoo`; a worktree cut +from `main` will not compile against this design. The route-solver claims +(`map_routes.ex`, `esi/api_client.ex`) were verified on both branches and are +identical. + +Facts established by inspection, with the constraints each imposes. + +| Evidence | Constraint it imposes | +|---|---| +| `MapEventRelay` forwards *every* event type to `DiscordDispatcher` (`map_event_relay.ex:164`); non-kill events fall into the catch-all `do_dispatch/2` clause | The hook point already exists. No relay change is needed. | +| `DiscordDispatcher` is a **singleton** GenServer whose moduledoc argues at length for bounding the two enrichment steps that block it | The route solver must never run on this process. | +| `Routes.find/5` is a blocking HTTP call to the route builder (30s receive timeout); every LiveView caller wraps it in `Task.async` (`map_routes_event_handler.ex:98`) | Evaluation runs in a supervised task with an explicit budget. | +| `Routes.find/5` caches on a hash of `params`, and `params` includes the *filtered connection list* (`map_routes.ex:203`, key built at `:225`) | A topology change misses the cache **only when it changes that list**. `:add_system` alone does not, so an add-then-link pair costs one round trip, not two. Debouncing is still worth having, but the cache โ€” not the debounce โ€” is what bounds solver load. | +| `Routes.find/5` swallows solver errors into `{:ok, %{routes: [], systems_static_data: []}}` (`map_routes.ex:75-77`), **and** falls back to `Esi.get_routes_eve/4` on a custom-route error (`map_routes.ex:249`), which is currently a stub returning `success: false` per hub inside `{:ok, ...}` (`esi/api_client.ex:76`) | `Routes.find/5` **cannot** distinguish outage from no-path. See "Distinguishing failure from no-route". | +| `do_find_routes/4` calls `String.to_integer/1` on both `origin` and each hub (`map_routes.ex:94-95`) | `origin` and `hubs` must be **strings**. Passing an integer `home_system_id` raises. | +| `MapDiscordWebhook.role` is stored as `:text` with an Ash `one_of` constraint (`map_discord_webhook.ex:199`) | Adding `:route` needs **no DB migration**. | +| `unique (notification_id, role)` identity (`map_discord_webhook.ex:229`) | One route webhook per map, enforced by the database. | +| `Router` moduledoc: the role reaching `SystemName.display_name/3` must be a literal matched atom, never a threaded variable, because that resolver is the map-local-names privacy boundary | A `:route` clause must be written explicitly, not passed through. | +| `SystemClass.wormhole_classes/0` is the canonical wormhole class list | Reuse it; do not restate class ids. | +| No "home system" concept exists anywhere in the codebase | It must be introduced. `Map.hubs` is a different, user-facing feature and is not reused. | + +## Decisions + +### 1. Trigger โ€” any topology change, debounced + +Re-evaluate on `:add_system`, `:connection_added`, `:connection_updated`, +`:connection_removed`, and `:deleted_system`. + +The two removal events are load-bearing, not symmetry for its own sake: the +transition table only runs during an evaluation, and an evaluation only happens +on one of these events. Without them a closed route leaves the stored state at +`{:qualifying, N}` indefinitely, so a route that closes and later re-opens at the +same or a worse jump count never alerts again โ€” only a strictly shorter re-open +gets through. + +Adding a system never creates a route by itself: a system appears with no edges, +and the path only becomes reachable when the connection is added โ€” usually +milliseconds later, sometimes much later when someone drags a link manually. +`:connection_updated` is included because an EOL or mass-crit flag change alters +which paths the solver will accept. + +Triggering on `:add_system` alone would be the literal reading of the request and +would silently never fire for the common scan-then-link workflow. + +### 2. Home system โ€” a field on the Discord notification config + +`home_system_id` lives on `MapDiscordNotification`, not on `Map`. + +The Discord settings LiveView component already exists and can host the field, +so no new settings surface is needed. Scoping it to this feature also avoids +asserting a definition of "home" that the rest of the application would then +have to honour. + +`Map.hubs` is deliberately not reused: it is an existing user-facing list with +different semantics, and reordering hubs for the routes widget would silently +change who gets alerted. + +### 3. Jump counting โ€” total hops in the full path + +Every hop in the solver's path counts, wormhole and gate alike. A chain three +wormholes deep with a highsec exit two gates from Jita is five jumps. + +This matches what the routes widget already shows the user, so the alert and the +UI agree. Counting only k-space gate jumps would report a nine-wormhole-deep +chain as "3 jumps". + +### 4. Security rule โ€” highsec k-space only, wormholes exempt + +A path qualifies when every **non-wormhole** system on it has security >= 0.45. + +- Wormhole systems (`SystemClass.wormhole_classes/0`) are exempt. J-space + security is approximately -1.0, so a naive `security >= 0.5` filter would + classify every wormhole in the map's own chain as nullsec and reject every + route. The chain is how you get out; it is not part of the security claim. +- The threshold is **0.45, not 0.5**. EVE rounds 0.45 up to 0.5 for display, so + 0.5 would wrongly reject genuine highsec systems. +- Pochven/Triglavian and Edencom systems are excluded via solver settings + (below). Zarzakh is already in `@default_avoid_systems` (`map_routes.ex:40`). +- The `security` value from `CachedInfo.get_system_static_info/1` is not reliably + a float โ€” `RouteBuilderClient.parse_security/1` handles float, integer, and + binary forms. The Evaluator must parse the same three shapes, and an + unparseable value disqualifies the route (see "Failure posture"). + +### 5. Threshold โ€” `route_max_jumps`, inclusive, default 5 + +"Less than 6 jumps" literally means at most 5. The column is an **inclusive** +upper bound with a default of `5`, so the shipped behaviour matches the request +exactly and the UI reads honestly: "max jumps: 5" is the same set as "fewer than +6". + +A default of `6` compared with `<` would make the stored number mean something +different from what it says. This is the one ambiguity in the request that was +resolved by decision rather than by asking, and it is recorded here for that +reason. + +### 6. Destination โ€” Jita hardcoded + +`30000142`, a module constant. Only `route_max_jumps` is user-configurable. +Supporting other trade hubs is a later change, not a launch requirement. + +### 7. Discord destination โ€” new `:route` role, falling back to `:system` + +`:route` joins the `MapDiscordWebhook` role enum. When no `:route` row exists, +route alerts go to the `:system` webhook โ€” the same fallback pattern `Router` +already uses for `:character`. + +Existing maps therefore need no configuration, and a separate logistics channel +is purely opt-in. + +Disabled destinations **drop; they do not reroute**, matching the rule +`RouterTest` asserts deliberately today. + +### 8. Solver settings โ€” fixed server-side, tuned for hauling + +There is no user in this code path, so settings are pinned rather than read from +any widget preference: + +```elixir +%{ + include_eol: false, + include_mass_crit: false, + include_frig: false, + include_cruise: true, + avoid_pochven: true, + avoid_edencom: true, + avoid_triglavian: true, + include_thera: false +} +``` + +`include_mass_crit: false` and `include_frig: false` differ from the module +defaults: the alert exists for logistics, and a crit or frigate-sized connection +will not pass a hauler. + +`include_thera: false` is the non-obvious one. A Thera-based path can qualify +without anything on the map changing, so including it would attribute a +public-data route to whatever unrelated system someone happened to add. Keeping +it off means every alert is genuinely caused by the map's own chain. + +## Architecture + +### Components + +| Module | Responsibility | +|---|---| +| `Discord.RouteWatcher` | GenServer, one per map. Owns the debounce timer, the last-known-route state, its `config_version`, and the solver task ref. Never blocks on the task. Nothing else touches route state. | +| `Discord.RouteWatcherSupervisor` | `DynamicSupervisor` + `Registry`, started only when webhooks are globally enabled. Mirrors `Discord.WorkerSupervisor`. Exposes `notify(map_id)`, starting the watcher on demand. | +| `Map.RouteAlert.Evaluator` | Pure function: solver output + settings -> `{:qualifying, %{jumps:, path:, exit_system:}}` \| `:none` \| `:unknown`. All security and jump-counting rules live here. Its three return values are exactly the watcher's three states, so the watcher does no translation. | +| `Map.MapRoutes.find_strict/5` | New sibling of `find/5` in an existing module. Identical params assembly and caching; returns `{:error, reason}` instead of falling back to the `get_routes_eve/4` stub. The only change to existing source. | +| `Discord.EmbedFormatter.format_route_alert/2` | New function on the existing formatter. | +| `Discord.Router.route_destination/1` | `:route` webhook only, with the existing disabled-drops rule. No `:system` fallback: the Path field is full chain topology, and a killmail channel has not consented to receive it. | + +Keeping the Evaluator separate from the Watcher is the main structural call: it +means every rule in the request is verified with synthetic input, without +standing up a GenServer or mocking a route builder. + +### Why a per-map GenServer + +Considered and rejected: + +- **Stateless tasks with Cachex debounce.** Least code, but cache-based + check-then-set is racy: two events 50ms apart can both pass and produce two + concurrent solver calls and two alerts for one change. Fixing it properly means + reinventing a per-map serialization point, which is what the watcher already is. +- **Global dirty-set sweeper.** Coalescing is free and it is the one place to + bound route-builder load instance-wide โ€” a genuine advantage, since that service + is shared. Traded away for up to 30s of latency and a single point of failure. + **This is the documented escape hatch** if route-builder load becomes a problem: + the watcher's internals barely change, only who calls it. + +### Data flow + +1. `map_server_systems_impl.ex:943` / `map_server_connections_impl.ex:779` + broadcast the topology events (unchanged). +2. `MapEventRelay` fans them to `DiscordDispatcher` (unchanged). +3. A new `do_dispatch/2` clause matches the five topology types + (`:add_system`, `:connection_added`, `:connection_updated`, + `:connection_removed`, `:deleted_system` โ€” removals included, so a route + that closes and later re-opens at the same jump count is still announced), + checks the global gate and the **already-cached** notification config for + `route_alerts_enabled?` and a non-nil `home_system_id`, then casts + `RouteWatcherSupervisor.notify(map_id)`. No DB hit, no HTTP; the singleton + never blocks. +4. The watcher arms a 10s debounce timer, re-arming on each notify, with a 60s + ceiling so a continuously-scanned chain is still evaluated. +5. On fire: `Task.Supervisor.async_nolink` running + + ```elixir + Routes.find_strict( + map_id, + [@jita_system_id_str], + Integer.to_string(home_system_id), + @solver_settings, + false + ) + ``` + + **Both `origin` and `hubs` must be strings.** `do_find_routes/4` calls + `String.to_integer/1` on each (`map_routes.ex:94-95`), so passing the integer + `home_system_id` straight through raises on every evaluation. `@jita_system_id_str` + is the constant `"30000142"`; the conversion of `home_system_id` happens at the + call site, not in the schema โ€” the column stays an integer. + + **The trailing `false` is `hubs_limit_reached?`, not "avoid wormholes".** + `find/5`'s `true` clause (`map_routes.ex:80-91`) skips the solver entirely and + fabricates a `success: false` placeholder per hub; its only callers pass + `is_hubs_limit_reached` (`map_routes_event_handler.ex:96,105`). Route alerts + always pass `false`. Reading it as "avoid wormholes" and passing `true` would + silently disable the feature while looking like a security tightening. + +6. **The watcher does not block on the task.** It stores `{task_ref, started_at}` + in its state and returns immediately, handling the result in + `handle_info({ref, result}, state)` and the failure in + `handle_info({:DOWN, ref, ...}, state)`. A `Process.send_after/3` deadline + message enforces the 20s budget and calls `Task.shutdown(task, :brutal_kill)` + from the timeout handler. + + `Task.yield(20_000) || Task.shutdown(:brutal_kill)` โ€” the idiom the enrichment + steps in `DiscordDispatcher` use โ€” is **wrong here**. It parks the watcher for + up to 20 seconds, during which it cannot receive the notify messages that are + supposed to set the re-run flag. Those messages would sit in the mailbox and be + processed *after* the stale result was already published, so a connection + closing mid-solve could still produce an "opened" alert. The dispatcher can + afford to block because it is enriching a payload it already holds; the watcher + cannot, because incoming events invalidate the work in flight. + + Notifies arriving mid-solve set a re-run flag rather than queuing timers; the + result handler re-arms immediately when the flag is set, and **discards** the + in-flight result rather than publishing it. + +7. Evaluate, compare with previous state, and on a reportable transition format + and call `WorkerSupervisor.deliver(webhook.id, [message])`, reusing the + existing per-webhook rate-limited HTTP queue. + +## Alert semantics + +State per map is three-way, not a boolean: +`:unknown` (never successfully evaluated) | `:none` | `{:qualifying, jumps}`. + +| Transition | Action | +|---|---| +| `:unknown` or `:none` -> qualifying | Post "opened" | +| qualifying(j) -> qualifying(k), k < j | Post "improved to k jumps" | +| qualifying(j) -> qualifying(k), k >= j | Silent; store k | +| qualifying -> none | Silent clear, so a reopen alerts again | + +### Distinguishing failure from no-route + +`Routes.find/5` returns `{:ok, ...}` even when the route builder fails. Reading +that as "no route" would silently clear state during an outage, and recovery +would re-announce a route that never closed. + +**`Routes.find/5` as it stands cannot make this distinction, and the design +must not pretend otherwise.** On a custom-route error it falls back to +`Esi.get_routes_eve/4` (`map_routes.ex:249`), whose body is currently a stub: the +real per-destination call is commented out, and it fabricates one +`%{"success" => false}` entry per hub and wraps it in `{:ok, ...}` +unconditionally (`esi/api_client.ex:76`). An outage therefore produces a payload +**byte-identical** to a genuine no-path. Building the three-state model on top of +`Routes.find/5` would clear state during every outage and fire a false "route +opened" alert on recovery โ€” the exact failure the three-state model exists to +prevent. + +**Resolution: a strict variant.** Add `Routes.find_strict/5` alongside +`Routes.find/5` in `map_routes.ex` โ€” same signature, same params assembly, same +cache key and TTL, but it does **not** fall back to `get_routes_eve/4`. On a +`get_routes_custom/3` error it returns `{:error, reason}`. Existing callers keep +`Routes.find/5` and their current behaviour; nothing else changes. This keeps the +connection filtering, avoid list, and static-data hydration in one place rather +than duplicating them into the watcher. + +The watcher then reads: + +- `{:error, _}` โ€” solver unreachable or errored -> `:unknown`. Keep prior state, + log, emit telemetry, do not alert and do not clear. +- `{:ok, %{routes: []}}` โ€” no entries at all -> `:unknown`, same handling. The + solver is not expected to return this for a valid request. +- `{:ok, %{routes: entries}}` where every entry carries `success: false` / + `has_connection: false` -> `:none`. Genuine no-path. Clear. +- otherwise -> hand the entries to the Evaluator. + +**Verify before implementing** whether `get_routes_eve/4` is stubbed on +`guarzo/zoo` as it is on `main`. If it has been restored to a real ESI call there, +the fallback becomes a legitimate degraded result rather than a fabricated one โ€” +but the strict variant is still the right call, because an ESI-only path ignores +the map's wormhole connections and cannot answer this question at all. + +### At-most-once, matching the dispatcher + +State is written **before** delivery, matching the posture the dispatcher +moduledoc argues for: a delivery failure loses one alert rather than repeating +it. `{:error, :not_running}` reverts the write, exactly as +`handle_delivery_result/4` does today, since nothing was enqueued. + +State is mirrored to Cachex on every write and rehydrated in `init/1`, so a +watcher that crashes and is restarted by its supervisor resumes against the +state it already had rather than re-announcing a still-open route. + +That rehydration is **in-lifetime only**. `:discord_route_alert_cache` is a +plain in-memory Cachex instance with no disk warmer and no dump/load, and +watchers start lazily, so a node restart loses every entry: after a deploy the +first qualifying topology event on each configured map creates a fresh watcher +at `:unknown` and re-announces a route that was already open. The route is +genuinely open, so this is noise rather than a false alert, but on an instance +with many configured maps a deploy produces a burst of pings. Persisting the +state across restarts is deliberately left as follow-up work. + +### State identity is versioned by config + +Persisted state keyed on `map_id` alone is wrong, because the state's meaning +depends on the configuration that produced it. `{:qualifying, 4}` recorded +against home system A says nothing about home system B, and a threshold change +from 5 to 3 can leave a stored `{:qualifying, 4}` that now describes a route +which no longer qualifies. + +Left unversioned, three concrete bugs follow: + +- Changing `home_system_id` and gaining a qualifying route to the *new* home is + compared against the *old* home's state and suppressed as "no change". +- Lowering `route_max_jumps` leaves a stored qualifying state that the next + evaluation reads as a regression rather than a closure. +- Disabling and re-enabling route alerts resumes against pre-disable state, so a + route that opened while disabled is silently attributed to whatever unrelated + topology event fires next โ€” and never announced. + +**Resolution:** the stored value carries a `config_version` โ€” a hash of +`{home_system_id, route_max_jumps, solver_settings_version}`. On rehydrate and on +every evaluation the watcher compares it against the current config; a mismatch +discards the stored state and resets to `:unknown` rather than to `:none`. +`:unknown` is the correct reset target: it means "we have never evaluated *this* +configuration", so the next qualifying result posts an "opened" alert, which is +the honest message after a config change. + +The third bug is **not** covered by the version hash: the hash is deterministic, +so disabling and re-enabling reproduces exactly the value already stored. It is +covered by eviction instead โ€” the notification's update hook calls +`RouteWatcherSupervisor.stop_watcher/1` whenever the record lands with route +alerts off, which stops the watcher and drops its cache entry, so re-enabling +starts from `:unknown`. + +The config change itself does not trigger an evaluation โ€” the watcher is +event-driven and will pick it up on the next topology event. Alerting on save +would mean a settings screen posts to Discord, which is surprising. + +## Message, mentions, and privacy + +### Embed + +Green, titled `Highsec route to Jita โ€” 4 jumps`. The path renders home -> ... -> +Jita, with map-local names for wormhole systems and security shown for k-space +ones. The exit system gets its own field. + +### Role resolution is literal + +An explicit `:route` clause resolves map-local names. The `:system` fallback +passes a literal `:system`. The role is never threaded through as a variable โ€” +see the `Router` moduledoc for why that boundary matters and why a Discord post +cannot be recalled. + +### The channel is trusted, not merely defaulted + +A route alert *is* the chain topology; the path is the entire message. There is +no version of this that is safe in a public channel. The `:route` destination +must be documented as trusted in the UI helper text. + +### Mentions + +#### Why not `VoiceParticipants` + +`Discord.VoiceParticipants` (shipped in PR #125) already pings people on kill +notifications, by resolving whoever is currently in a voice channel. **Route +alerts deliberately do not reuse it**, and this is the load-bearing decision in +this section. + +The two features address opposite populations. Someone in voice is online, on +the map, and watching the chain change in real time โ€” they will see a new highsec +route before any bot tells them. The audience for a route alert is precisely the +people who are **not** connected: the hauler who would undock if they knew a +five-jump highsec path to Jita existed right now. Pinging voice participants +would reliably notify the one group that does not need notifying, and at 04:00 +with an empty voice channel it would notify nobody at all. + +So the mention set must be *configured*, not *observed*. This is why +`mention_targets` exists as its own mechanism rather than as a call into +`VoiceParticipants`. + +#### What is already built + +Two mechanical constraints shape the delivery, and `VoiceParticipants` has +already solved both โ€” **reuse it, do not reimplement**: + +1. **Mentions inside embeds do not ping.** Discord only fires notifications for + mentions in the top-level `content` field. A `<@&...>` in an embed renders as a + blue chip and notifies nobody. `VoiceParticipants.prepend_to_messages/2` + already puts a prefix into `content` on the first chunk and leaves embeds + untouched; it is mention-source-agnostic and takes route alerts' prefix as + readily as voice's. +2. **Handles do not work.** `@guarzo` in a webhook payload is plain text. + Pinging requires the snowflake: `<@123...>` for a user, `<@&123...>` for a role. + +`VoiceParticipants.mention_prefix/2` also already implements the character-budget +truncation. Whether route alerts share it or format their own short prefix is an +implementation detail for the plan, not a design decision. + +#### Where configured targets live + +Snowflake IDs are **guild-scoped**, which decides where they are stored. A role +id from one corp's Discord is meaningless in another's and renders as a broken +mention. Wanderer is multi-tenant; each map's webhook points at a different +guild. An instance-wide secret would put one guild's id into every guild's +channel. + +`MapDiscordWebhook` *is* the guild binding, one row per destination. Mention +targets are stored there as an array column, each entry validated against +`^(user|role):\d{17,20}$` โ€” parseable, renderable, and unable to hold a handle +that would silently fail. It generalizes for free: kill alerts on the `:system` +and `:character` rows can opt in later without a second mechanism. + +A global `DISCORD_MENTIONS_ENABLED` env gate lets an operator stop all pings +instance-wide during an incident without editing per-map config. This is the +piece a Fly secret is genuinely right for, and it follows the existing `Env` +gate pattern. + +**Ping on open only.** The "improved to N jumps" update posts with no `content` +field, keeping the ping meaningful on a chain that is being actively scanned. + +### Mention injection is a real risk + +`allowed_mentions` appears **nowhere in `lib/` or `test/`** on `guarzo/zoo` today. +That is currently a latent gap rather than a live vulnerability: the only three +writers of `"content"` are a static overflow string +(`embed_formatter.ex:133`), a static test message (`discord_dispatcher.ex:146`), +and voice mentions built from guild data. No user-controlled text reaches +`content`, and Discord does not fire notifications for mentions inside embeds, so +nothing is exploitable as shipped. + +This design keeps it that way โ€” system names, including user-supplied +`temporary_name` values, go in the **embed**, never in `content`. But the gap is +one careless formatter change away from mattering, and this feature is the one +adding a second `content` writer. + +Every request therefore sends `allowed_mentions` with `parse: []` plus an +explicit allowlist of exactly the configured ids. This makes the mention set a +closed allowlist and neutralizes the injection class entirely. **This is required +even when no mentions are configured** โ€” an empty allowlist with `parse: []` is +what makes an unconfigured map safe. + +Adding `allowed_mentions` to the shared payload builder would also harden the +existing kill and voice paths. Whether to do that here or as a separate change is +a scoping call for the plan; this spec's requirement is only that the route-alert +path never posts `content` without it. + +## Data model + +Migration on `map_discord_notifications_v1`: + +| Column | Type | Default | Notes | +|---|---|---|---| +| `route_alerts_enabled?` | boolean | `false`, not null | Separate from `enabled?`, which gates kills. Ships off. | +| `home_system_id` | integer | null | Required when `route_alerts_enabled?` is true (Ash validation). | +| `route_max_jumps` | integer | `5`, not null | Inclusive upper bound. See decision 5. | + +Migration on `map_discord_webhooks_v1`: + +| Column | Type | Default | Notes | +|---|---|---|---| +| `mention_targets` | text array | `{}`, not null | Each entry matches `^(user\|role):\d{17,20}$`. | + +`:route` joins the `role` `one_of` constraint โ€” an app-level change only. + +Every new action needs a `define(...)` entry in `code_interface`, per project +convention. Config changes must invalidate the dispatcher's cache via the +existing `after_transaction` hooks โ€” never `after_action`. + +## Failure posture + +**This feature fails closed**, which inverts the posture of every neighbouring +module and is a deliberate choice rather than an oversight. + +Any system on the path whose static info will not resolve disqualifies the route. +Kills fail *open* because a parse regression that silences notifications looks +like a quiet map. Here the asymmetry runs the other way: announcing a highsec +route that is not one gets a freighter killed, while a missed alert costs nothing +but an alert. + +Other failure paths: + +- Watcher crash -> supervisor restart -> rehydrate from Cachex. +- Solver timeout or crash -> keep state, log, emit telemetry. +- Telemetry on `[:wanderer_app, :discord, :route_alert]` with an outcome tag, + matching the existing enrichment telemetry shape. + +## Testing + +**Evaluator** carries the bulk, all with synthetic input and no HTTP: + +- wormhole exemption (a J-space hop does not disqualify) +- the 0.45 boundary in both directions (0.45 qualifies, 0.4 does not) +- Pochven rejection +- unresolvable static info disqualifies (fail-closed) +- jump counting includes wormhole hops +- `{:error, _}` and `routes: []` -> `:unknown`, versus every entry `success: false` + -> `:none` + +**`Routes.find_strict/5`**: returns `{:error, _}` on a solver error rather than +falling back, and is otherwise byte-identical to `find/5` on the success path +(assert both against the same stubbed solver response, including the cache key). + +**Router**: `:route` selection, `:system` fallback, and the +disabled-drops-never-reroutes assertion mirroring the existing `RouterTest`. + +**Watcher**: debounce coalescing, all four transitions, solver-failure-keeps-state, +restart rehydration, and specifically: + +- a notify delivered *while a solver task is in flight* is received and sets the + re-run flag โ€” the test that would fail under a blocking `Task.yield` +- an in-flight result is discarded, not published, when the re-run flag is set +- a stored state whose `config_version` no longer matches resets to `:unknown`, + and the next qualifying result posts "opened" rather than being suppressed +- the 20s deadline fires and shuts the task down without killing the watcher + +Requires `Routes.find_strict` behind a swappable impl, the way +`NotableItems.impl()` already does it. + +**Dispatcher**: topology events notify the watcher only when configured; the kill +path is unaffected. + +**Formatter**: embed shape, and `allowed_mentions` present with `parse: []` on +every request including the no-mentions case. + +## Scope + +**In scope, easy to forget:** `map_notifications_component.ex` needs the +home-system picker, the max-jumps field, a `:route` webhook row, and the +mention-targets input with helper text explaining that ids are required and the +channel is trusted. + +**Changes to existing source in `map_routes.ex`.** The headline change is +`find_strict/5`, which is purely additive. Planning surfaced two supporting +changes to shared code that the design did not anticipate: + +- **A swappable ESI seam.** `WandererApp.Esi.get_routes_custom/3` and + `get_routes_eve/4` are called directly (`map_routes.ex:232,249`) with no + `Application.get_env` seam, and `Esi.MockBehaviour` does not declare them, so + the solver cannot be stubbed at all today. `find_strict/5` is untestable + without this. The seam follows `CorpTickers.esi_client/0` + (`corp_tickers.ex:172`) and defaults to the real module, so no existing caller + changes behaviour. +- **`hydrate_static_data/1` extracted** from `find/5`'s body so `find_strict/5` + reuses it rather than duplicating the static-info hydration. + +Both touch the code path behind the live routes widget. `find/5`'s observable +behaviour must be unchanged, and the plan carries an explicit regression test +asserting the `get_routes_eve/4` fallback still happens for `find/5`. **This is +where a reviewer should look hardest.** + +**Branch base:** this must be built on `guarzo/zoo`, not `origin/main`. See +"Repository evidence". + +**Explicitly out:** no frontend map changes; no per-user route alerts; no "route +closed" message; destination stays Jita-only; no change to any kill path +behaviour; **no voice-participant mentions on route alerts** โ€” see "Why not +`VoiceParticipants`", and treat any later PR that unifies the two mention sources +as a regression unless it argues against that reasoning directly. + +## Assumptions that may change + +- A webhook can ping a role that is not marked "mentionable" when the role id is + in `allowed_mentions.roles`. Believed correct but **must be verified with a live + test** before the UI promises it. +- The 10s debounce and 60s ceiling are guesses. Telemetry on + `[:wanderer_app, :discord, :route_alert]` is what will tune them, exactly as the + enrichment thresholds were left to be tuned. +- Route-builder load is acceptable at the expected number of enabled maps. The + dirty-set sweeper above is the escape hatch if not. + +## Verification performed + +Dependency setup and a baseline test run were **not** executed at the time this +spec was written. They have since been established in this worktree, and the +implementation plan records them under "Baseline": `mix deps.get` (exit 0), +`MIX_ENV=test mix compile` (exit 0), and the Discord/API test subset (321 tests, +0 failures). `mix ecto.setup` is still required for the Ash migration in Task 3. + +The worktree this spec was drafted in was initially cut from `origin/main`, which +does not contain the Discord stack. It has since been reset onto `guarzo/zoo`. +Any future worktree for this work must be based the same way. + +## Review history + +An independent review (Codex, read-only, against the repository) returned REVISE +with six findings. All six are folded into the text above: + +| Finding | Where it landed | +|---|---| +| Discord stack absent from the checkout | "Repository evidence" branch-dependency note; artifact of the worktree base, now reset | +| Failure and no-route are not distinguishable via `Routes.find/5` | "Distinguishing failure from no-route" โ€” rewritten around `find_strict/5` | +| Integer `home_system_id` crashes `String.to_integer/1` | Data flow step 5; evidence table row | +| Watcher state not versioned by config | New "State identity is versioned by config" | +| Blocking `Task.yield` defeats the re-run flag | Data flow step 6 โ€” result handling is now asynchronous | +| "A topology change always misses the cache" is false | Evidence table cache row, corrected | diff --git a/docs/superpowers/specs/2026-08-07-discord-voice-mentions-design.md b/docs/superpowers/specs/2026-08-07-discord-voice-mentions-design.md new file mode 100644 index 000000000..2122f9c82 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-discord-voice-mentions-design.md @@ -0,0 +1,185 @@ +# Discord Voice-Participant Mentions for Kill Notifications โ€” Design + +**Date:** 2026-08-07 +**Status:** Approved (brainstorming session) +**Source of truth ported from:** `wanderer-notifier` (`WandererNotifier.Infrastructure.Adapters.Discord.VoiceParticipants`) + +## Goal + +When a kill notification is posted to a map's **system** Discord webhook, prepend +individual `<@user_id>` mentions for every member currently active in a voice +channel of the configured Discord guild, so people on comms get pinged about +kills in mapped systems. This ports proven behavior from `wanderer-notifier` +into wanderer's native Discord kill-notification pipeline. + +## Decisions (from brainstorming) + +| Decision | Choice | +|---|---| +| Deployment model | Self-hosted, single guild | +| Which notifications get mentions | System webhook (`role == :system`) only; character webhook untouched | +| Enable/disable | Env vars only: presence of `DISCORD_BOT_TOKEN` **and** `DISCORD_GUILD_ID` enables the feature. No DB migration, no UI | +| Throttling | None โ€” one ping per delivered kill event (wanderer already batches kills per event) | +| How voice state is obtained | Embedded Nostrum bot (approach A). Discord exposes voice states only over the gateway; webhooks/REST cannot list them | + +### Rejected alternatives + +- **HTTP call to wanderer-notifier** โ€” couples two deployments; notifier is not + guaranteed to run alongside this instance. +- **Hand-rolled minimal gateway client** โ€” reimplements the fragile part + (identify/heartbeat/resume/reconnect) of a library already vetted in + wanderer-notifier. + +## Architecture + +Three new pieces, all in the existing `WandererApp.ExternalEvents.Discord` +namespace: + +### 1. Nostrum dependency + conditional gateway startup + +- `mix.exs`: add `{:nostrum, "~> 0.10", runtime: false}`. `runtime: false` + guarantees the bot never starts implicitly (including in tests). +- New module `WandererApp.ExternalEvents.Discord.VoiceGateway`, added to the + supervision tree near the existing Discord worker infrastructure. At boot it + reads config; if **both** `DISCORD_BOT_TOKEN` and `DISCORD_GUILD_ID` are set + it calls `Application.ensure_all_started(:nostrum)`; otherwise it is a no-op + and the instance behaves exactly as today. +- Gateway intents: `:guilds`, `:guild_voice_states` โ€” both non-privileged. + Voice states are folded into Nostrum's default ETS `GuildCache` passively; + no consumer logic is needed. If the installed Nostrum version requires a + consumer process to boot, include a minimal no-op `Nostrum.Consumer` + (verify at implementation time). +- Config in `config/runtime.exs`, mirroring wanderer-notifier: only configure + `:nostrum` (token + intents) when `DISCORD_BOT_TOKEN` is present and + `config_env() != :test`. Do **not** copy the notifier's `cache_guilds:` / + `caches: []` keys โ€” they are not recognized Nostrum options (the default + `GuildCache` is what serves voice states). +- Expose `WandererApp.Env.discord_bot_token/0`, `discord_guild_id/0`, and + `discord_voice_mentions_enabled?/0` (true iff both are set and the guild id + parses as a positive integer). +- One-time operational step (documented, not coded): create a Discord + application/bot, invite it to the guild with no permissions beyond guild + visibility, and set the two env vars. + +### 2. `Discord.VoiceParticipants` + +Near-verbatim port of the notifier module: + +- `get_active_voice_mentions/0` โ†’ reads guild id from `Env`, delegates. +- `get_active_voice_mentions/1` โ†’ `GuildCache.get!(guild_id)`, then: + - exclude users whose voice state is in the guild's AFK channel, + - keep only users in real voice channels (Discord channel types 2 + `GUILD_VOICE` and 13 `GUILD_STAGE_VOICE`), + - map to `"<@user_id>"`, dedup. +- Every failure path โ€” bot not started, guild not cached yet, malformed + config, unexpected struct shape โ€” rescues/returns `[]`. +- Logging: the notifier's per-call `Logger.info` narration becomes + `Logger.debug`, including the "users present but all filtered out" + diagnostic โ€” at warning level it would fire on every kill whenever the + guild's only voice occupants sit in the AFK channel, which is normal + idling, not misconfiguration (final-review ruling). +- The `GuildCache` read sits behind a single seam (an injectable + guild-fetching function or a public function accepting a guild struct) so + unit tests supply guild fixtures without Nostrum running. + +### 3. Injection in `DiscordDispatcher.deliver_to/5` + +In `lib/wanderer_app/external_events/discord_dispatcher.ex` (`deliver_to/5`, +currently line ~667): + +``` +entries +|> EmbedFormatter.format_batch(system_name) +|> maybe_prepend_voice_mentions(role) +|> then(&WorkerSupervisor.deliver(webhook.id, &1)) +``` + +- `maybe_prepend_voice_mentions(messages, :system)` when + `Env.discord_voice_mentions_enabled?()`: fetch mentions; if non-empty, set or + prepend the joined mention string on the **first** message's `"content"` + field (separated from any existing content by a space). Embeds are untouched. +- **Content-length cap (fail-open):** Discord rejects `content` over 2,000 + characters, and the worker treats a 400 as a permanent event failure that + feeds the auto-disable counter (`worker.ex:306`). The mention prefix is + therefore built against a fixed budget (1,800 characters, leaving headroom + for any existing content): mentions are appended one at a time until the + next would exceed the budget, and the remainder are silently dropped. A + partially-tagged ping is acceptable; a rejected kill notification is not. +- All other roles, feature disabled, or empty mentions: messages pass through + unchanged โ€” no stray whitespace, no empty content key. +- One event = one ping regardless of chunk count. Mentions (first chunk) and + the formatter's overflow "โ€ฆand N more kills not shown." line (last chunk, + `embed_formatter.ex:129`) can never collide: overflow requires more than 30 + kills, and at 10 embeds per message that is always a multi-chunk event. +- Discord's default `allowed_mentions` for webhook payloads pings users + mentioned in `content`, so no payload changes beyond the string. + +## Data flow + +**Boot:** supervision tree starts `VoiceGateway` โ†’ (if configured) Nostrum +connects to the gateway, identifies with the two intents โ†’ Discord pushes +`VOICE_STATE_UPDATE` events โ†’ Nostrum maintains voice states in ETS. No +polling. + +**Per kill event:** pipeline unchanged through matching, routing, and +formatting. At `deliver_to/5`, for the `:system` partition only, mentions are +read from ETS (microseconds, no network โ€” cannot block or add latency on the +dispatch path) and prepended to chunk one's content. Delivery, retry, chunk +spacing, and status tracking in `Discord.Worker` are untouched โ€” mentions ride +inside the already-queued message payloads. + +## Error handling + +Invariant: **voice tagging can never cost a kill notification.** + +| Condition | Behavior | +|---|---| +| Env vars absent | `VoiceGateway` no-op; dispatcher predicate false; messages unchanged | +| Malformed `DISCORD_GUILD_ID` | One `Logger.warning` at boot; feature off; no per-kill noise | +| Nostrum fails to start (bad token, network) | Error logged by `VoiceGateway`; supervision tree continues; kills deliver without pings. `VoiceGateway` must not link the app's fate to Discord's gateway | +| Guild not yet cached (startup, cache flush) | `GuildCache.get!` raises โ†’ rescued โ†’ `[]` โ†’ message sends without pings | +| Gateway down / reconnecting after the cache was populated | The ETS cache stays readable but **stale**: users who left voice may still be pinged, and new joiners missed, for the duration of the reconnect window. **Accepted tradeoff** โ€” the ping is best-effort comms awareness, the notifier has the identical behavior in production, and a gateway-freshness signal is not worth its complexity here. Nostrum resyncs the cache on resume/reconnect | +| Nobody in voice, or everyone in AFK channel | `[]` โ†’ clean message, no prepended whitespace | + +## Observability + +An empty mention list from a broken gateway must be distinguishable from +"nobody in voice": + +- The existing `[:wanderer_app, :discord_dispatcher, :dispatched]` telemetry + event (`discord_dispatcher.ex:690`) gains a `mention_count` measurement for + `:system`-role dispatches when the feature is enabled (`0` when the lookup + returned empty or rescued; absent when the feature is off). +- `VoiceParticipants` logs at `debug` the participant count per lookup, and at + `debug` when a lookup rescues (with the error), so a persistently broken + gateway is visible in debug logs without adding per-kill warning noise. +- `VoiceGateway` logs at `info` on successful start and at `error` when + Nostrum fails to start โ€” the one-time signals an operator actually needs. + +## Testing + +- **`VoiceParticipantsTest`** (unit, async): guild fixtures injected through + the cache seam. Cases: AFK-channel users excluded; users in non-voice + channels excluded; stage channels (type 13) included; duplicate user ids + deduped; nil/absent voice_states, nil channels map, non-integer guild id, + and raise-from-cache all return `[]`. +- **Dispatcher tests** (extend existing): mentions prepended only for + `:system` role; multi-chunk event โ†’ only first chunk's content modified and + the last chunk's overflow line untouched; mention list exceeding the + 1,800-character budget โ†’ prefix truncated at a mention boundary, message + still valid; feature disabled โ†’ byte-identical messages; mention lookup + raising does not prevent delivery; `mention_count` present in dispatch + telemetry when enabled. +- **Not covered by automated tests:** the live gateway connection. Verified + manually once against the real guild (bot online, user in voice, kill in a + mapped system โ†’ ping received; user in AFK channel โ†’ no ping). +- Nostrum never starts in `mix test` (`runtime: false`, no test config), so + the suite stays hermetic. + +## Explicit exclusions + +- No per-map or per-webhook toggle, no UI, no DB migration. +- No mention throttling/cooldown. +- No multi-guild support; one guild id per instance. +- No changes to the character webhook path, embed formatting, worker retry + logic, or upstream (non-zoo) behavior โ€” this is a zoo-fork feature. diff --git a/docs/superpowers/specs/2026-08-09-discord-killmail-notification-fixes-design.md b/docs/superpowers/specs/2026-08-09-discord-killmail-notification-fixes-design.md new file mode 100644 index 000000000..e8fefb838 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-discord-killmail-notification-fixes-design.md @@ -0,0 +1,346 @@ +# Discord killmail notification fixes โ€” design + +Two production defects in the Discord killmail notification path, fixed +independently. They share no code and can land in either order. + +1. Notifications fire for systems that are no longer on the map. +2. Already-posted killmails are posted again after an application restart. + +## Defect 1 โ€” kills for systems no longer on the map + +### Root cause + +Removing a system from a map is a **soft delete**. `MapSystemRepo.remove_from_map/2` +sets `visible: false` on the `MapSystem` row rather than destroying it +(`lib/wanderer_app/repositories/map_system_repo.ex:49-58`). + +The fan-out that decides which maps receive a killmail is +`WandererApp.Kills.Subscription.SystemMapIndex`, an ETS index of +`system_id -> [map_id]`. It builds that index with `MapSystemRepo.get_all_by_map/1`, +which applies **no `visible` filter** +(`lib/wanderer_app/kills/subscription/system_map_index.ex:98`). + +The index therefore maps every system a map has *ever* contained to that map, +permanently. `MapIntegration.broadcast_kill_to_maps/1` fans out on it +(`lib/wanderer_app/kills/subscription/map_integration.ex:164-188`), into both the +in-app `{:map_kill, โ€ฆ}` PubSub and `ExternalEvents.broadcast(map_id, :map_kill, โ€ฆ)`, +which reaches `DiscordDispatcher`. + +Nothing downstream re-checks map membership. `Router.route/3` consults only +`excluded_systems` and `wh_only` (`lib/wanderer_app/external_events/discord/router.ex:75-98`). + +Two pieces of evidence that the missing filter is an oversight rather than a +deliberate choice: + +- The sibling function `MapIntegration.get_tracked_system_ids/0`, which decides + which systems to subscribe to upstream, uses `get_visible_by_map/1` + (`map_integration.ex:98`). +- `MapSystem` carries a partial index specifically for this filter: + `index [:map_id], name: "map_system_v1_map_id_visible_index", where: "visible = true"` + (`lib/wanderer_app/api/map_system.ex:44`). + +Staleness is a **secondary** cause, and the refresh path is weaker than it +looks. `SystemMapIndex.refresh/0` is called only from the `:ok` branch of +`MapEventListener.do_update_subscriptions/1` +(`lib/wanderer_app/kills/map_event_listener.ex:177-183`) โ€” that is, only after a +kills-client subscription update *succeeds*. While the client is connecting or +disconnected, the retry path replaces the refresh entirely +(`map_event_listener.ex:220-237`). Worse, the listener subscribes to individual +map topics only in its `:resubscribe_to_maps` handler, first fired 60 seconds +after init (`map_event_listener.ex:26-29`, `:111-133`), so a map started since +the last resubscribe emits system-removal events that nobody is listening for. + +In those cases the only backstop is the index's 5-minute periodic refresh +(`system_map_index.ex:12`, `:127-129`). The exposure is therefore **up to five +minutes** of notifications for a system that was just removed โ€” not the +sub-second window an earlier draft of this document assumed. That is the same +user-visible symptom as the permanent case, merely bounded, so it warrants a fix +of its own. + +### Fix + +Two parts. The first removes the permanent defect; the second bounds the +residual staleness window. + +**1. Filter the index by visibility.** `SystemMapIndex.fetch_all_map_systems/0` +builds from `MapSystemRepo.get_visible_by_map/1` instead of `get_all_by_map/1`. + +```elixir +# lib/wanderer_app/kills/subscription/system_map_index.ex:98 +- case WandererApp.MapSystemRepo.get_all_by_map(map.id) do ++ case WandererApp.MapSystemRepo.get_visible_by_map(map.id) do +``` + +**2. A fail-open membership guard in the dispatcher.** A `:map_kill` batch +carries a single `solar_system_id`, so the `:map_kill` clause of `do_dispatch` +(`discord_dispatcher.ex:223`) checks that one id against +the live map cache before doing any other work. `WandererApp.Map.remove_system/2` +drops the system from that cache immediately (`lib/wanderer_app/map.ex:508-517`), +so it is strictly fresher than the index. + +The guard is **fail-open**: it drops the batch only when the map cache read +*succeeds* and the system is absent from it. Any failure to read โ€” the map not +being in `:map_cache`, the cache being unavailable โ€” allows the batch through. + +```elixir +# Drops only on a positive "this map does not have that system". +# `systems` is keyed by solar_system_id (`lib/wanderer_app/map.ex:20,486`). +defp system_on_map?(map_id, system_id) do + case WandererApp.Map.get_map(map_id) do + {:ok, %{systems: systems}} when is_map(systems) -> Map.has_key?(systems, system_id) + _ -> true + end +rescue + # `Cachex.get/2` RAISES against an unstarted cache rather than returning an + # error tuple โ€” the same contract `Matcher.tracked_eve_ids/1` rescues + # (`matcher.ex:53-60`). Without this the guard would not fail open, it would + # crash the dispatcher and lose the whole batch. + _ -> true +end +``` + +Fail-open is what makes this guard safe to add, and it is the reason an earlier +draft's objection to it no longer applies: a cold or unavailable cache cannot +silently drop a real kill, because an unreadable cache is not a positive finding +of absence. This is the same `:unknown`-is-not-`:not_involved` distinction that +`Matcher` and `Router` already turn on +(`lib/wanderer_app/external_events/discord/router.ex:18-28`). + +Note the deliberate asymmetry between the two parts: the index fix affects the +in-app kills widget as well, while the guard is Discord-only. Bounding the +staleness window for the UI is not worth a second guard โ€” nobody is looking at a +map they just removed a system from. + +### Blast radius + +The guard in part 2 is Discord-only. Part 1 changes the index, which has three +consumers: + +| Consumer | Effect of the fix | +|---|---| +| `map_integration.ex:164-174` โ€” in-app `{:map_kill, โ€ฆ}` PubSub to the map UI | Removed systems stop showing kills in the kills widget | +| `map_integration.ex:177-188` โ€” `ExternalEvents.broadcast` โ†’ Discord | The defect being fixed | +| `kills/client.ex:868` โ€” a log line attributing kills to maps | Log accuracy only | + +The UI change is intentional and in the same direction: a system that was +removed from the map should not light up with kill activity. This is a behaviour +change beyond Discord and is accepted rather than worked around. + +### Rejected alternatives + +**Restricting the index to started maps.** `MapIntegration.get_tracked_system_ids/0` +builds from `Cache.lookup("started_maps", [])` โ€” maps with a live GenServer โ€” +while `SystemMapIndex` builds from every persisted map. Rejected: a map with no +running process would receive no Discord notifications at all, which is +precisely when notifications are most wanted, and `started_maps` is empty +immediately after a boot (`lib/wanderer_app/map/map_manager.ex:55`). It is a +separate behaviour change with its own risks, not a fix for this defect. + +**Making `SystemMapIndex.refresh/0` unconditional** โ€” moving it out of the `:ok` +branch of `do_update_subscriptions/1` so it also runs when the kills client is +disconnected. Tempting, and it would shrink the staleness window at the source. +Rejected for this change because the listener's subscription lifecycle is load- +bearing for the upstream subscription set, not just the index, and reworking it +is a larger change than either defect warrants. The fail-open guard bounds the +symptom without touching that lifecycle. Worth revisiting separately. + +### Verification + +A regression test against `SystemMapIndex`: a map with one visible system and +one `visible: false` system; `get_maps_for_system/1` returns the map id for the +first and `[]` for the second. Shaped so that a future refactor back to +`get_all_by_map/1` fails. + +For the guard, three dispatcher tests: a batch whose system is on the map posts; +a batch whose system is absent from a readable map cache drops; and a batch for +a map that is **not** in `:map_cache` at all posts, pinning the fail-open +behaviour so a later "tidy-up" cannot quietly turn it fail-closed. + +## Defect 2 โ€” duplicate notifications after a restart + +### Root cause + +`DiscordDispatcher` deduplicates on `"#{map_id}:#{killmail_id}"` marks held in +`:discord_dedup_cache`, a plain in-memory Cachex instance +(`lib/wanderer_app/application.ex:151-154`). Every mark is lost on restart. + +On reconnect the kills client re-joins `killmails:lobby` with its subscribed +system list (`lib/wanderer_app/kills/client.ex:686-692`) and the upstream service +replays recent killmails. The dispatcher's moduledoc already names this hazard โ€” +"an upstream replay burst on reconnect" (`discord_dispatcher.ex:105`). + +The existing guard is `kill_fresh?/3` against `Env.discord_max_killmail_age_seconds/0`, +which defaults to **3600** (`lib/wanderer_app/env.ex:100`). After a restart the +dedup marks are gone and the age filter admits an hour of history, so up to an +hour of already-posted killmails is posted a second time. + +### Fix + +For a grace period after the dedup marks are lost, the freshness filter uses a +much tighter maximum age. Replayed history is dropped because it is old; a +killmail that genuinely occurs during the window still posts. + +**The window belongs to the dedup cache's lifecycle, not the dispatcher's.** +This is the part that is easy to get wrong. `:discord_dedup_cache` and +`DiscordDispatcher` are separate children of a `:one_for_one` supervisor +(`lib/wanderer_app/application.ex:150-154`, `:204-213`, `:270-298`), so their +restarts are independent, and only one of the two asymmetries is benign: + +| Event | Marks | Window must | +|---|---|---| +| Full application restart | lost | arm | +| Dedup cache crashes alone | lost | **arm** | +| Dispatcher crashes alone | intact | not arm (harmless if it does) | +| Kills-client reconnect, no restart | intact | not arm | + +Keying the window off `DiscordDispatcher.init/1` gets row 2 exactly backwards: +every mark is gone and the window never arms, which is precisely the +duplicate-post scenario this fix exists to prevent. + +So the window is derived from the dedup cache itself, via a sentinel stored in +that cache: + +- On dispatcher init, read the sentinel from `:discord_dedup_cache`. + - **Absent** โ€” the cache is new, so its marks are gone. Write + `arm_until = System.monotonic_time(:millisecond) + grace_ms`, with no TTL. + - **Present** โ€” the cache survived. Honour the stored `arm_until` as it is. +- A batch is inside the window when `System.monotonic_time(:millisecond) < arm_until`. + +The sentinel carries an absolute deadline rather than a TTL, so an expired +window is still a *present* sentinel and a dispatcher-only restart cannot re-arm +it. Monotonic time is safe here because the cache and the dispatcher share a VM, +and it makes the window immune to wall-clock adjustment. + +Kills-client reconnects need no special handling: they do not restart either +component, the marks are intact, and ordinary dedup already covers the replay. + +`do_dispatch/3` then resolves the maximum age **once per batch**: + +```elixir +max_killmail_age_seconds = + if within_startup_grace?(arm_until), + do: Env.discord_startup_max_killmail_age_seconds(), + else: Env.discord_max_killmail_age_seconds() +``` + +Mechanically that means `init/1`, which today returns a bare `%{}` +(`discord_dispatcher.ex:90`), stores the resolved `arm_until` in state, and +`handle_cast({:dispatch_event, โ€ฆ})` (`:212-216`) passes it into `do_dispatch`, +which gains a third argument. The existing two-argument clauses at `:285` and +`:303` gain it too, and ignore it. + +This preserves the existing once-per-batch invariant that +`discord_dispatcher.ex:230-237` documents at length: resolving config per +killmail turns one misconfigured deployment into a warning-per-kill log flood. + +`kill_fresh?/3` is unchanged. It already takes the maximum age as an explicit +third argument, so both call paths flow through the same comparison. + +### Observability + +A killmail dropped by the startup window currently leaves **no trace at all**: +the age filter falls out of the `with` chain into a catch-all `:ok` +(`discord_dispatcher.ex:223-269`), and telemetry is emitted only after delivery +or an enqueue failure (`:762-766`, `:794-800`). During an incident, "did we +suppress it, or did we never receive it?" would be unanswerable โ€” the one +question this feature makes worth asking. + +So the fix adds: + +- A telemetry event `[:wanderer_app, :discord, :killmail_dropped]` with + `%{count: n}` and metadata `%{reason: :startup_age | :age | :duplicate, + map_id: map_id}`. Three reasons, because conflating them would defeat the + purpose: `:startup_age` is the new suppression, `:age` is the pre-existing + hour limit, and `:duplicate` is ordinary dedup. +- One `Logger.info` per batch when `:startup_age` drops anything, giving the + count and the remaining window. At info, not debug: it fires at most once per + batch for at most ten minutes after a restart, and it is the line an operator + will search for. + +### Configuration + +Both keys join the existing `:external_events` keyword list. They do **not** +share a validator, and the difference is load-bearing: + +| Key | Default | Validator | Meaning | +|---|---|---|---| +| `discord_startup_grace_seconds` | 600 | non-negative integer (new) | How long after the marks are lost the tighter age applies. `0` disables the window. | +| `discord_startup_max_killmail_age_seconds` | 120 | existing `validate_positive_integer/3` | Maximum killmail age during that window | + +`validate_positive_integer/3` warns and substitutes the default for anything not +`> 0` (`lib/wanderer_app/env.ex:285-294`). That is right for the age โ€” a zero or +negative maximum age would drop every real killmail, silently and invisibly, +which is exactly the failure it was written to catch. It is **wrong** for the +grace period, where `0` is a legitimate setting meaning "no startup window", and +routing it through that helper would turn "disabled" into "600 seconds plus a +warning" โ€” the opposite of what the operator asked for, and the reason the test +configuration below could not otherwise work. + +So `discord_startup_grace_seconds` gets a sibling validator, +`validate_non_negative_integer/3`, accepting `>= 0` and falling back with the +same warning on a negative or non-integer value. Both fall back loudly rather +than silently. + +**Why a 10-minute default rather than 2.** A long window is nearly free, because +it only ever drops *old* killmails. The replay burst arrives when the kills +client joins the channel, which can be minutes after boot when the upstream +service is slow to accept the connection; a 2-minute window would miss it +entirely. Ten minutes covers that without ever silencing a kill that actually +just happened. + +### Accepted cost + +During the grace period, a genuinely delayed killmail โ€” upstream lag exceeding +120 seconds โ€” is dropped. This is the same trade the module already makes for +at-most-once dedup (`discord_dispatcher.ex:26-32`): a dropped kill remains +visible in the kills widget and on zKillboard, while a duplicate post in a chat +channel is irreversible. + +### Rejected alternatives + +**Blanket suppression for N minutes after boot.** Simpler to explain, but it +drops genuinely new killmails for the whole window, and a crash-looping node +would stay permanently silent. The tighter-freshness variant is the same amount +of code and has neither property. + +**Persisting the dedup marks** to Postgres or a disk-backed cache. Strictly +correct โ€” no killmail is lost, and it would also survive a replay longer than +any grace window. Rejected as disproportionate for this defect: it requires a +new table, a migration, and an expiry sweep, and puts a database write on the +per-killmail dispatch path. + +### Verification + +`config/test.exs` sets `discord_startup_grace_seconds` to `0`, which the +non-negative validator honours as "disabled". No existing dispatcher test is +then silently pulled inside the window โ€” every test in +`test/unit/external_events/discord_dispatcher_test.exs` calls +`start_supervised!(DiscordDispatcher)` (`:86-110`) and would otherwise begin +inside a live 600-second grace period, quietly changing what the existing age +tests assert. + +New tests set both keys explicitly and assert: + +- A 5-minute-old killmail posts normally, but is dropped inside the window. +- A 30-second-old killmail posts in both cases. +- The window expires: past `discord_startup_grace_seconds`, the ordinary + 3600-second limit applies again. +- **Lifecycle**: restarting the dispatcher alone with the sentinel present does + not re-arm the window; clearing the dedup cache and restarting the dispatcher + does. These two are the point of the sentinel design and the case an earlier + draft got backwards, so they are asserted directly rather than inferred. +- A `:startup_age` drop emits the telemetry event with that reason, and an + ordinary age drop emits `:age` โ€” pinning that the two stay distinguishable. +- Each new `Env` accessor returns its default when unset, returns a configured + value when set, and falls back with a warning on an invalid value. For + `discord_startup_grace_seconds` that explicitly includes `0` being **honoured** + rather than replaced, matching the style of the existing coverage in + `test/unit/external_events/discord_killmail_age_test.exs`. + +## Out of scope + +- Route alerts. Their state cache is deliberately TTL-less + (`application.ex:155-161`) and neither defect touches that path. +- The `:discord_notification_cache` config cache and `Matcher`'s tracked-pilot + cache. Both are correctly invalidated already. +- Any change to at-most-once delivery semantics. diff --git a/fly.toml b/fly.toml index a5ca5b1a8..26db4f610 100644 --- a/fly.toml +++ b/fly.toml @@ -1,29 +1,85 @@ -# fly.toml app configuration file generated for wanderer-test on 2024-05-31T22:56:48+04:00 +# EXACTLY ONE MACHINE. This is an architectural constraint, not a preference. +# Map state lives in node-local Cachex tables and character trackers register in +# a node-local Registry (WandererApp.Character.TrackerRegistry); PubSub uses the +# PG2 adapter with no clustering configured. Two machines would serve two +# independent halves of the same map. # -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. # +# So: no autoscaling, no raising min/max machines, and no DNS_CLUSTER_QUERY +# until map state is cluster-aware. [deploy].strategy must stay 'rolling' for +# the same reason โ€” 'bluegreen' and 'canary' both boot a second machine +# alongside the one still running. -app = 'wanderer-test' -primary_region = 'ams' +app = 'wanderer' +# A wrong region here fails silently โ€” Fly just deploys to the wrong place. +primary_region = 'iad' kill_signal = 'SIGTERM' +# The default 5s is too tight for this supervision tree to shut down cleanly, +# and every deploy is a full restart of the single machine. +kill_timeout = 30 swap_size_mb = 512 [build] [deploy] - release_command = '/app/bin/migrate.sh' + # Migrations run against DIRECT_DATABASE_URL: Ecto's migration lock is a + # session-scoped advisory lock, and PgBouncer cannot carry session state + # across transactions, so through the pooled endpoint the lock is taken and + # lost on a different backend. DATABASE_URL stays pooled for the app itself. + release_command = "/bin/sh -lc 'DATABASE_URL=\"$DIRECT_DATABASE_URL\" /app/bin/migrate.sh'" + strategy = 'rolling' [env] - PHX_HOST = 'wanderer-test.fly.dev' PHX_SERVER = 'true' PORT = '8080' + # PHX_HOST and WEB_APP_URL are secrets, not env: they are per-deployment and + # change at cutover. WEB_APP_URL must use https:// โ€” force_https below means + # browsers always arrive over https, and with an http:// scheme every + # LiveView websocket upgrade fails check_origin while /health still returns + # 200, so nothing automated catches it. + # + # WEB_EXTERNAL_SCHEME stays 'http' because TLS terminates at Fly's proxy. + # Setting 'https' rebinds the endpoint to HTTP_PORT (default 80, so nothing + # listens on internal_port), turns on force_ssl which 301s the plain-HTTP + # health check forever, and points at /certs files absent from this image + # (config/runtime.exs:430-444). + WEB_EXTERNAL_SCHEME = 'http' + # Always true on Fly โ€” the kills service is reachable only over the IPv6-only + # 6PN network โ€” and the default is "false", which fails silently and retries + # forever. Its companions WANDERER_KILLS_SERVICE_ENABLED and + # WANDERER_KILLS_BASE_URL are secrets instead: they embed the kills app name. + WANDERER_KILLS_IPV6 = 'true' + # Enriches Discord killmail embeds with notable dropped items + # (external_events/discord_dispatcher.ex:301); default is "false". Must be + # exactly 'true' or 'false' โ€” runtime.exs:508 pipes this through + # String.to_existing_atom/1, so '1' or 'yes' raises at boot and takes the + # machine down rather than falling back to the default. Read only at boot, + # so a deploy/restart is required. The tuning companions + # WANDERER_NOTABLE_ITEMS_{THRESHOLD_ISK,LIMIT,TIMEOUT_MS} stay at their + # defaults (50_000_000 / 5 / 1500) until set here. + WANDERER_NOTABLE_ITEMS_ENABLED = 'true' + # Enabled to collect the character location-tracking defect counters added in + # #146 (:location_flag_cleared / :location_flag_repaired / + # :location_skipped_while_active). Two of the three have no log line, so + # without this they record nothing. + # + # Must be exactly 'true' or 'false' โ€” runtime.exs:455 pipes this through + # String.to_existing_atom/1, so any other value raises at boot and takes the + # machine down rather than falling back to the default. + PROMEX_DISABLED = 'false' [http_service] internal_port = 8080 force_https = true auto_stop_machines = 'off' - auto_start_machines = false - min_machines_running = 0 + # 'true' only starts a machine that already exists, so it cannot weaken the + # one-machine guarantee. It restores automatic recovery when the machine ends + # up stopped rather than crashed (interrupted deploy, manual stop, host + # maintenance); with 'false' the app stays down until a human intervenes. + auto_start_machines = true + # Inert today: Fly only applies this when auto_stop_machines is 'stop' or + # 'suspend', and always-on comes from 'off' above. Kept as documented intent. + min_machines_running = 1 processes = ['app'] [http_service.concurrency] @@ -31,10 +87,25 @@ swap_size_mb = 512 hard_limit = 1000 soft_limit = 1000 -[[vm]] - size = 'shared-cpu-1x' + [[http_service.checks]] + grace_period = '30s' + interval = '15s' + method = 'GET' + path = '/health' + protocol = 'http' + timeout = '5s' +# Scrapes the PromEx endpoint into Fly's managed Prometheus. Port must track +# METRICS_PORT in config/runtime.exs, which defaults to 4021 and is not set in +# [env] above โ€” change one and you change both. The scrape happens over the +# private 6PN network, so this port is deliberately absent from [http_service] +# and never publicly routable. [[metrics]] port = 4021 path = '/metrics' - https = false + +# Sized for the fifteen Cachex tables and five Finch pools started in +# lib/wanderer_app/application.ex. Revisit against observed RSS. +[[vm]] + size = 'shared-cpu-2x' + memory = '2gb' diff --git a/lib/wanderer_app/api.ex b/lib/wanderer_app/api.ex index 7dcf9266c..13ff0233c 100644 --- a/lib/wanderer_app/api.ex +++ b/lib/wanderer_app/api.ex @@ -38,5 +38,7 @@ defmodule WandererApp.Api do resource WandererApp.Api.MapPing resource WandererApp.Api.MapInvite resource WandererApp.Api.MapWebhookSubscription + resource WandererApp.Api.MapDiscordNotification + resource WandererApp.Api.MapDiscordWebhook end end diff --git a/lib/wanderer_app/api/map.ex b/lib/wanderer_app/api/map.ex index c71c8efcf..2272ba3a9 100644 --- a/lib/wanderer_app/api/map.ex +++ b/lib/wanderer_app/api/map.ex @@ -14,7 +14,10 @@ defmodule WandererApp.Api.Map do repo(WandererApp.Repo) table("maps_v1") - migration_defaults scopes: "'{wormholes}'" + # This value is injected verbatim into generated migrations. It must be + # Elixir source for a list of strings: `'{wormholes}'` is a charlist, which + # generated a default of the character codes of the literal "{wormholes}". + migration_defaults scopes: ~s(["wormholes"]) end json_api do @@ -69,6 +72,7 @@ defmodule WandererApp.Api.Map do define(:duplicate, action: :duplicate) define(:admin_all, action: :admin_all) define(:restore, action: :restore) + define(:set_intel_source_map, action: :set_intel_source_map) end calculations do @@ -89,7 +93,24 @@ defmodule WandererApp.Api.Map do end actions do - defaults [:create, :read, :destroy] + defaults [:create, :read] + + # Custom destroy so the map's route-alert watcher is stopped. The + # notification row's own :destroy already does this, but hard-deleting a map + # never runs it: `map_discord_notifications_v1` declares + # `reference :map, on_delete: :delete`, and a PostgreSQL cascade removes the + # child row without any Ash lifecycle hook firing. The watcher would keep + # running against a map that no longer exists, and its route_state would sit + # in the TTL-less :discord_route_alert_cache forever. + destroy :destroy do + primary? true + require_atomic? false + + # `after_transaction`, not `after_action`: an after_action hook fires + # while the DELETE is still uncommitted, so a rollback would leave the + # watcher stopped and the cache evicted for a map that still exists. + change after_transaction(&__MODULE__.after_destroy/3) + end read :by_slug do get? true @@ -195,6 +216,22 @@ defmodule WandererApp.Api.Map do require_atomic? false end + update :set_intel_source_map do + accept [:intel_source_map_id] + require_atomic? false + + validate fn changeset, _context -> + source_id = Ash.Changeset.get_attribute(changeset, :intel_source_map_id) + map_id = changeset.data.id + + if source_id != nil and source_id == map_id do + {:error, field: :intel_source_map_id, message: "a map cannot be its own intel source"} + else + :ok + end + end + end + update :mark_as_deleted do accept([]) require_atomic? false @@ -305,6 +342,18 @@ defmodule WandererApp.Api.Map do end end + @doc false + def after_destroy(_changeset, {:ok, record}, _context) do + # Stops the map's route-alert watcher AND evicts its cached route_state + # (RouteWatcherSupervisor.stop_watcher/1 does both, and the eviction is + # deliberately outside its running?/0 guard). + WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor.stop_watcher(record.id) + + {:ok, record} + end + + def after_destroy(_changeset, other, _context), do: other + # Generate a unique slug from map name defp generate_unique_slug(name) do base_slug = @@ -444,6 +493,17 @@ defmodule WandererApp.Api.Map do has_many :transactions, WandererApp.Api.MapTransaction do public? false end + + belongs_to :intel_source_map, WandererApp.Api.Map do + attribute_writable? true + public? true + allow_nil? true + end + + has_many :intel_subscriber_maps, WandererApp.Api.Map do + destination_attribute :intel_source_map_id + public? false + end end # SSE Subscription Validation diff --git a/lib/wanderer_app/api/map_chain_passages.ex b/lib/wanderer_app/api/map_chain_passages.ex index a0a291558..001e9dae1 100644 --- a/lib/wanderer_app/api/map_chain_passages.ex +++ b/lib/wanderer_app/api/map_chain_passages.ex @@ -90,19 +90,23 @@ defmodule WandererApp.Api.MapChainPassages do p.solar_system_source_id == ^input.arguments.from and p.solar_system_target_id == ^input.arguments.to and p.inserted_at >= ^input.arguments.after, - select: [p, c] + select: %{ + id: p.id, + ship_type_id: p.ship_type_id, + ship_name: p.ship_name, + mass: p.mass, + inserted_at: p.inserted_at, + character: %{ + eve_id: c.eve_id, + name: c.name, + corporation_id: c.corporation_id, + corporation_ticker: c.corporation_ticker, + alliance_id: c.alliance_id, + alliance_ticker: c.alliance_ticker + } + } ) |> WandererApp.Repo.all() - |> Enum.map(fn [passage, character] -> - %{ - id: passage.id, - ship_type_id: passage.ship_type_id, - ship_name: passage.ship_name, - mass: passage.mass, - inserted_at: passage.inserted_at, - character: character - } - end) |> Enum.sort_by(& &1.inserted_at, :desc) |> then(&{:ok, &1}) end diff --git a/lib/wanderer_app/api/map_discord_notification.ex b/lib/wanderer_app/api/map_discord_notification.ex new file mode 100644 index 000000000..04f816dff --- /dev/null +++ b/lib/wanderer_app/api/map_discord_notification.ex @@ -0,0 +1,317 @@ +defmodule WandererApp.Api.MapDiscordNotification do + @moduledoc """ + Per-map Discord kill-notification policy. + + Exactly one row per map. Destinations live in `MapDiscordWebhook` children โ€” + this row holds only what applies to the map as a whole: the kill switch, + wormhole-only filtering, excluded systems, and focus corporations. + """ + + use Ash.Resource, + domain: WandererApp.Api, + data_layer: AshPostgres.DataLayer + + postgres do + repo(WandererApp.Repo) + table("map_discord_notifications_v1") + + references do + reference :map, on_delete: :delete + end + end + + code_interface do + define(:create, action: :create) + define(:update, action: :update) + define(:destroy, action: :destroy) + define(:by_id, get_by: [:id], action: :read) + define(:by_map, action: :by_map, args: [:map_id]) + end + + actions do + default_accept [ + :map_id, + :enabled?, + :wh_only, + :excluded_systems, + :focus_corp_ids, + :route_alerts_enabled?, + :home_system_id, + :route_max_jumps + ] + + defaults [:read] + + # Custom destroy, following map_webhook_subscription.ex:51-58. The default + # destroy would leave a stale cache entry AND leave the delivery workers + # draining their queues into webhooks the user just removed. + destroy :destroy do + primary? true + require_atomic? false + + # The webhook ids MUST be captured before the delete runs. PostgreSQL + # executes ON DELETE CASCADE as a referential action of the DELETE + # statement itself, not at commit, so by the time an after_action hook + # runs the child rows are already gone and `Ash.load(record, :webhooks)` + # returns an empty list. That failure is silent: no error, no stopped + # workers, and queued messages keep posting to webhooks the user just + # removed. + # `stash_webhook_ids/2` must stay in `before_action` for the reason above. + # The cleanup, though, runs `after_transaction`: an `after_action` hook + # fires while the DELETE is still uncommitted, so a killmail arriving in + # that window reloads the configuration, still reads the pre-delete rows + # and re-caches them for the full TTL โ€” kills keep posting to webhooks the + # user just removed. On rollback it would also have stopped the workers + # and evicted the cache for a policy that still exists. + change before_action(&__MODULE__.stash_webhook_ids/2) + change after_transaction(&__MODULE__.after_destroy/3) + end + + # Creates the policy row, optionally with its :system destination in the + # same transaction. + # + # `webhook_url` used to be required, which encoded a "a system webhook + # always exists" invariant. That invariant was never true after the fact โ€” + # nothing stops the row being destroyed later โ€” and it had a real cost in + # the settings UI: route alerts are a separate feature that borrows the + # same plumbing, and requiring a kill webhook up front meant an operator + # who only wanted route alerts had to configure a kill channel first. + # + # Routing already tolerates the absence. `Router.route/3` resolves the + # `:system` destination through `usable/1`, which drops on `nil`, so a + # policy row with no kill destination simply posts no kills. + create :create do + primary? true + argument :webhook_url, :string, allow_nil?: true + + # `manage_relationship`'s `transform:` option isn't available on the + # installed Ash version (3.9.0) โ€” its opts schema has no such key. This + # explicit form builds the input map itself instead: one :system child, + # written in the same transaction as the parent. Skipped entirely when no + # URL was supplied, rather than passing `[]` โ€” an empty list with + # `type: :create` is a no-op either way, but the branch says why. + change fn changeset, _context -> + case Ash.Changeset.get_argument(changeset, :webhook_url) do + url when is_binary(url) and url != "" -> + Ash.Changeset.manage_relationship( + changeset, + :webhooks, + [%{webhook_url: url, role: :system}], + type: :create + ) + + _absent -> + changeset + end + end + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + update :update do + primary? true + require_atomic? false + + # Explicit, so `default_accept` cannot expose `:map_id`: re-parenting a + # notification would move it and its webhook children to another map. + # The three route fields ARE deliberately in this list โ€” unlike + # `:map_id` there is no re-parenting risk, and route alert config is + # meant to be editable the same way the kill-switch fields are. + accept [ + :enabled?, + :wh_only, + :excluded_systems, + :focus_corp_ids, + :route_alerts_enabled?, + :home_system_id, + :route_max_jumps + ] + + change after_transaction(&__MODULE__.after_update/3) + end + + read :by_map do + argument :map_id, :uuid, allow_nil?: false + get? true + filter expr(map_id == ^arg(:map_id)) + + # Routing reads the cached value, and the cache stores whatever by_map + # returned โ€” so the webhooks must be loaded here or routing sees + # %Ash.NotLoaded{} instead of destinations. + prepare build(load: [:webhooks]) + end + end + + validations do + validate &__MODULE__.validate_home_system_required/2 + end + + attributes do + uuid_primary_key :id + + # The user-facing kill switch for the whole map. This stays on the parent + # even though each webhook now has its own enabled? flag: the two mean + # different things โ€” this one is intent, the child's is destination health โ€” + # and map-level intent cannot be inferred from the children. + attribute :enabled?, :boolean, default: true, allow_nil?: false + attribute :wh_only, :boolean, default: true, allow_nil?: false + + attribute :excluded_systems, {:array, :integer} do + default [] + allow_nil? false + end + + attribute :focus_corp_ids, {:array, :integer} do + default [] + allow_nil? false + end + + # Route alerts โ€” separate switch from `enabled?`, which gates kills. Ships + # off: an operator must opt a map in, not discover it firing unannounced. + attribute :route_alerts_enabled?, :boolean, default: false, allow_nil?: false + + # No "home system" concept exists anywhere else in the codebase (see the + # design doc's repository-evidence table) โ€” this is where it is defined, + # scoped to this feature. Nullable: a map with route alerts off need not + # have one set, and `validate_home_system_required/2` below is what + # enforces the combination that matters. + attribute :home_system_id, :integer + + # Inclusive upper bound (design decision 5): "less than 6 jumps" means + # "at most 5", so the stored number and the UI copy agree. + attribute :route_max_jumps, :integer do + default 5 + allow_nil? false + # 1 is the trivial floor (a route of zero jumps is "already there", not + # an alert). 20 is a generous ceiling: it is nowhere near a real hauling + # route in this feature's wormhole-plus-highsec shape, but it stops a + # typo (e.g. an extra digit) from asking the solver to treat every + # multi-region path as "qualifying" and firing constantly. + constraints min: 1, max: 20 + end + + create_timestamp :inserted_at + update_timestamp :updated_at + end + + relationships do + belongs_to :map, WandererApp.Api.Map do + attribute_writable? true + allow_nil? false + end + + has_many :webhooks, WandererApp.Api.MapDiscordWebhook do + destination_attribute :notification_id + end + end + + identities do + identity :unique_map_id, [:map_id] + end + + # Invalidation MUST run after the transaction, not after the action. `create` + # writes the parent and its :system child in one transaction, so an + # after_action hook drops the cache entry while both rows are still + # uncommitted. A killmail arriving in that window reloads the config, reads + # pre-commit state, finds nothing and caches the NEGATIVE `:none` marker, + # which then sticks for the cache's 5-minute TTL โ€” a map the user just + # configured posts nothing for five minutes, with no error anywhere. The same + # window on an update re-caches the old value. + # + # On rollback there is nothing to invalidate: the error result passes through + # untouched so a failed write cannot evict a still-correct cache entry. + @doc false + def invalidate_cache(_changeset, {:ok, record}, _context) do + WandererApp.ExternalEvents.DiscordDispatcher.invalidate_cache(record.map_id) + {:ok, record} + end + + def invalidate_cache(_changeset, other, _context), do: other + + # Update runs the same invalidation, plus one thing the create path does not + # need: when the record lands with route alerts OFF, the map's watcher must + # go away. Nothing else evicts it โ€” the dispatcher stops calling notify/1 for + # a disabled map (discord_dispatcher.ex), so the watcher's own + # "clear state when disabled" branch never runs, and `config_version/1` + # deliberately excludes `route_alerts_enabled?` so the stale + # `{:qualifying, N}` rehydrates byte-identical on re-enable. The result would + # be a permanently silent map: the route is still open at the same jump + # count, so the transition table takes the silent branch forever. + # `stop_watcher/1` stops the process AND evicts the cache entry, which is + # what makes the next enable start fresh at `:unknown`. + @doc false + def after_update(changeset, {:ok, record} = result, context) do + {:ok, _} = invalidate_cache(changeset, result, context) + + unless record.route_alerts_enabled? do + WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor.stop_watcher(record.map_id) + end + + {:ok, record} + end + + def after_update(_changeset, other, _context), do: other + + @doc false + def validate_home_system_required(changeset, _context) do + # get_attribute/2 reads the value the changeset WOULD produce โ€” the new + # value if it is being set, otherwise the record's current one โ€” so this + # catches both "enable with no home system yet" and "clear the home + # system while alerts are still on" in one check. + enabled? = Ash.Changeset.get_attribute(changeset, :route_alerts_enabled?) + home_system_id = Ash.Changeset.get_attribute(changeset, :home_system_id) + + if enabled? && is_nil(home_system_id) do + # The field is NAMED in the message, not left to the `field:` key. The + # settings tab renders Ash validation errors as a sentence in its own + # message region (`humanize_error/1`), so a field-scoped message alone + # surfaced as the orphan "is required when route alerts are enabled" โ€” + # with no indication of which of the three route fields it meant. + {:error, + field: :home_system_id, message: "Home system is required when route alerts are enabled"} + else + :ok + end + end + + @doc false + def stash_webhook_ids(changeset, _context) do + ids = + case Ash.load(changeset.data, :webhooks) do + {:ok, %{webhooks: webhooks}} when is_list(webhooks) -> Enum.map(webhooks, & &1.id) + _ -> [] + end + + Ash.Changeset.put_context(changeset, :webhook_ids, ids) + end + + @doc false + def after_destroy(changeset, {:ok, record}, _context) do + WandererApp.ExternalEvents.DiscordDispatcher.invalidate_cache(record.map_id) + + # Stop every destination's delivery worker: without this, anything already + # queued keeps posting to webhooks the user has just removed. The ids come + # from the changeset context because the FK cascade has already deleted the + # child rows by the time this hook runs โ€” reading them here would return an + # empty list and quietly stop nothing. + changeset.context + |> Map.get(:webhook_ids, []) + |> Enum.each(fn id -> + WandererApp.ExternalEvents.Discord.WorkerSupervisor.stop_worker(id) + end) + + # Stops the map's route-alert watcher AND evicts its cached route_state + # (RouteWatcherSupervisor.stop_watcher/1 does both): without the eviction + # a deleted notification's route_state would outlive the process in the + # TTL-less :discord_route_alert_cache, and a later + # `MapDiscordNotification.create/1` for the same map would rehydrate that + # stale state instead of starting fresh at :unknown. + WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor.stop_watcher(record.map_id) + + {:ok, record} + end + + # Rollback: the rows still exist, so neither the cache nor the workers may be + # touched. The error passes through untouched. + def after_destroy(_changeset, other, _context), do: other +end diff --git a/lib/wanderer_app/api/map_discord_webhook.ex b/lib/wanderer_app/api/map_discord_webhook.ex new file mode 100644 index 000000000..5ccbe4111 --- /dev/null +++ b/lib/wanderer_app/api/map_discord_webhook.ex @@ -0,0 +1,468 @@ +defmodule WandererApp.Api.MapDiscordWebhook do + @moduledoc """ + One Discord destination belonging to a `MapDiscordNotification`. + + The parent row holds per-map policy; each child row holds one webhook URL and + that destination's delivery health. Splitting them means a dead character + channel disables only itself โ€” before the split, a single `consecutive_failures` + counter on the parent would have switched off system-kill notifications too. + + The webhook URL is a credential โ€” anyone holding it can post arbitrary messages + to the channel โ€” so it is encrypted at rest and never rendered back in full. + """ + + use Ash.Resource, + domain: WandererApp.Api, + data_layer: AshPostgres.DataLayer, + extensions: [AshCloak] + + require Logger + + @discord_hosts ["discord.com", "discordapp.com", "ptb.discord.com", "canary.discord.com"] + + # Mirrors `WebhookDispatcher`'s threshold (webhook_dispatcher.ex:32): a run of + # 10 consecutive failures disables this destination. Only a 404 bypasses this. + @max_consecutive_failures 10 + + # Matches the :last_error attribute's max_length constraint, so an + # unexpectedly long error message is truncated rather than rejected. + @max_error_length 500 + + # Discord caps a channel name at 100 characters and a webhook name at 80. + # 100 with room to spare for the "#" prefix `ChannelInfo` adds, so a rename + # upstream can never fail the write that caches it. + @max_channel_label_length 128 + + postgres do + repo(WandererApp.Repo) + table("map_discord_webhooks_v1") + + references do + reference :notification, on_delete: :delete + end + end + + cloak do + vault(WandererApp.Vault) + attributes([:webhook_url]) + decrypt_by_default([:webhook_url]) + end + + code_interface do + define(:create, action: :create) + define(:update, action: :update) + define(:destroy, action: :destroy) + define(:by_id, get_by: [:id], action: :read) + define(:by_notification, action: :by_notification, args: [:notification_id]) + define(:set_enabled, action: :set_enabled) + define(:record_success, action: :record_success) + define(:record_failure, action: :record_failure, args: [:error]) + define(:disable, action: :disable, args: [:error]) + define(:cache_channel_info, action: :cache_channel_info) + end + + actions do + default_accept [:notification_id, :role, :webhook_url, :enabled?, :mention_targets] + + defaults [:read] + + create :create do + primary? true + validate {__MODULE__.ValidateWebhookUrl, []} + validate {__MODULE__.ValidateMentionTargets, []} + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + update :update do + primary? true + require_atomic? false + # NOT `default_accept`: that would let a caller re-parent a webhook by + # passing `notification_id`, moving the credential onto another map. It + # would also defeat `do_invalidate/1`, which resolves the notification + # from the record *after* the write and so would evict only the new map's + # cache โ€” the old map would keep routing to a destination it no longer + # owns for the rest of the TTL. `role` is immutable for the same reason: + # the unique (notification_id, role) identity is what makes "the system + # destination" addressable. + # + # `mention_targets` is safe to add here unlike `notification_id`/`role`: + # it carries no ownership semantics, only which snowflakes this + # destination pings. + accept [:webhook_url, :enabled?, :mention_targets] + validate {__MODULE__.ValidateWebhookUrl, []} + validate {__MODULE__.ValidateMentionTargets, []} + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + # Custom destroy, following the destroy action on + # `WandererApp.Api.MapDiscordNotification`. The default + # destroy would leave a stale cache entry AND leave this destination's + # delivery worker draining its queue into a webhook the user just removed. + # + # after_transaction for the same reason `invalidate_cache/3` uses it (see + # the comment above that function), plus one specific to destroy: an + # after_action hook would stop the delivery worker *before* the commit, so + # a rolled-back destroy would leave the row alive with its worker killed. + # Unlike the PARENT resource's destroy โ€” which must stash its children's + # ids before PostgreSQL runs the FK cascade inside the DELETE โ€” this hook + # needs nothing but the record it is handed. + destroy :destroy do + primary? true + require_atomic? false + + change after_transaction(&__MODULE__.after_destroy/3) + end + + read :by_notification do + argument :notification_id, :uuid, allow_nil?: false + filter expr(notification_id == ^arg(:notification_id)) + end + + update :set_enabled do + require_atomic? false + accept [:enabled?] + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + # Deliberately the ONE health action with no cache invalidation: it fires on + # every successful delivery, so evicting here would drop the routing cache on + # the hot path and defeat it. None of the four attributes below feeds a + # routing decision โ€” routing reads `enabled?`, which this never touches. The + # cost is a `last_delivery_at` in the settings UI that can lag by one TTL. + update :record_success do + require_atomic? false + accept [] + + change set_attribute(:last_delivery_at, &DateTime.utc_now/0) + change set_attribute(:consecutive_failures, 0) + change set_attribute(:last_error, nil) + change set_attribute(:last_error_at, nil) + end + + # Writes back what `ChannelInfo` resolved from Discord, so the settings tab + # renders a real channel name on open instead of waiting on two HTTP calls + # per destination. + # + # Like `record_success`, deliberately no cache invalidation: none of these + # attributes feeds a routing decision โ€” routing reads `enabled?` and + # `webhook_url` โ€” so evicting here would drop the routing cache every time a + # background refresh confirmed a name that had not changed. + # + # Its own action rather than widening `update`'s `accept`: these are cached + # values written by a background task, not user input, and keeping them off + # the user-facing action means a crafted form submit cannot claim this + # destination posts to `#some-innocent-channel`. + # + # `accept` alone does not achieve that. AshCloak's `SetUpEncryption` + # transformer rewrites *every* create/update/destroy action on this + # resource: it removes the cloaked attribute from `accept` and re-adds it + # as an action *argument* carrying the encrypting change. So `webhook_url` + # is submittable here no matter what `accept` lists, and the redirect this + # action was split out to prevent has to be rejected explicitly. + update :cache_channel_info do + require_atomic? false + accept [:channel_id, :channel_label, :channel_label_source, :guild_id] + + # Checks the argument map rather than `absent(:webhook_url)`: that builtin + # falls back to the attribute already on the row, so it would reject every + # call. Only an explicitly submitted argument lands in `arguments`. + validate fn changeset, _context -> + if Map.has_key?(changeset.arguments, :webhook_url) do + {:error, + field: :webhook_url, message: "cannot be changed while caching channel information"} + else + :ok + end + end + end + + # Increments the counter from the value re-read inside the change rather + # than from a possibly-stale in-memory copy, and disables this destination + # once the run reaches @max_consecutive_failures. + # + # This read-then-write is NOT atomic across nodes: two concurrent deliveries + # on separate nodes could each read N and write N+1, losing an increment. + # That is safe under the single-delivery-node assumption documented in the + # spec (one worker per webhook, one node), and the failure mode is benign โ€” a + # webhook disables slightly later than it should. If the app is ever + # clustered, replace this with an atomic SQL increment. + update :record_failure do + require_atomic? false + accept [] + argument :error, :string, allow_nil?: false + + change fn changeset, _ctx -> + current = + case Ash.get(__MODULE__, changeset.data.id) do + {:ok, fresh} -> fresh.consecutive_failures || 0 + _ -> Ash.Changeset.get_data(changeset, :consecutive_failures) || 0 + end + + next = current + 1 + + changeset = + changeset + |> Ash.Changeset.change_attribute(:consecutive_failures, next) + |> Ash.Changeset.change_attribute( + :last_error, + changeset |> Ash.Changeset.get_argument(:error) |> String.slice(0, @max_error_length) + ) + |> Ash.Changeset.change_attribute(:last_error_at, DateTime.utc_now()) + + if next >= @max_consecutive_failures do + Ash.Changeset.change_attribute(changeset, :enabled?, false) + else + changeset + end + end + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + + # Immediate disable, used only for a 404 (webhook deleted upstream, will + # never recover). Everything else goes through record_failure's threshold. + update :disable do + require_atomic? false + accept [] + argument :error, :string, allow_nil?: false + + change set_attribute(:enabled?, false) + change set_attribute(:last_error_at, &DateTime.utc_now/0) + + change fn changeset, _ctx -> + Ash.Changeset.change_attribute( + changeset, + :last_error, + changeset |> Ash.Changeset.get_argument(:error) |> String.slice(0, @max_error_length) + ) + end + + change after_transaction(&__MODULE__.invalidate_cache/3) + end + end + + attributes do + uuid_primary_key :id + + attribute :role, :atom do + allow_nil? false + constraints one_of: [:system, :character, :route] + end + + attribute :webhook_url, :string do + allow_nil? false + sensitive? true + constraints max_length: 2000 + end + + attribute :enabled?, :boolean, default: true, allow_nil?: false + + attribute :last_delivery_at, :utc_datetime + attribute :last_error, :string, constraints: [max_length: @max_error_length] + attribute :last_error_at, :utc_datetime + attribute :consecutive_failures, :integer, default: 0, allow_nil?: false + + # Cached Discord identity for this destination, resolved by + # `WandererApp.ExternalEvents.Discord.ChannelInfo` and refreshed in the + # background. None is a credential and none is `sensitive?`: the whole + # point is that they are safe to render on a screen that gets screenshotted, + # unlike the webhook id the settings tab used to show. `channel_id` is a + # public guild snowflake, and `channel_label` is the channel or webhook name + # the operator already sees in Discord. + # + # Nullable and always re-derivable: an instance with no bot token, or a + # webhook Discord will not answer for, simply leaves them nil and renders a + # masked hint. Nothing routes off any of these values. + attribute :channel_id, :string + attribute :channel_label, :string, constraints: [max_length: @max_channel_label_length] + + # Guild this destination's channel belongs to, decoded from the same + # `GET /channels/{id}` response that yields `channel_label`. Cached here so + # the mention typeahead can scope its role/member search on tab open without + # a round trip. A public snowflake, never a credential. + # + # nil whenever the bot could not answer for the channel โ€” which is exactly + # when the typeahead is unavailable anyway, so the same nil drives the + # manual-entry fallback. + attribute :guild_id, :string + + # Which tier produced `channel_label`: `:channel` for a real `#name` read + # from `GET /channels/{id}`, `:webhook` for the webhook's own nickname. + # + # Persisted rather than inferred from a leading "#", because a webhook may + # legitimately be named "#anything" and the UI must not claim that is a + # channel. nil means the row predates this column: unknowable after the + # fact, so `ChannelInfo` reports it as `:unknown`, makes no claim in the UI, + # and schedules a refresh that fills it in. + # + # `:atom` with `one_of` follows `role` above; a bare `:string` cannot carry + # the constraint (Ash's string type takes only length/match options). + attribute :channel_label_source, :atom do + constraints one_of: [:channel, :webhook_name] + end + + # Guild-scoped snowflakes to ping on this destination โ€” see the design + # doc's "Where configured targets live": these belong on the webhook row, + # not anywhere map- or instance-wide, because a role/user id from one + # guild is meaningless (and unrenderable) in another. + attribute :mention_targets, {:array, :string} do + default [] + allow_nil? false + end + + create_timestamp :inserted_at + update_timestamp :updated_at + end + + relationships do + belongs_to :notification, WandererApp.Api.MapDiscordNotification do + attribute_writable? true + allow_nil? false + end + end + + identities do + identity :unique_notification_role, [:notification_id, :role] + end + + @doc """ + Returns true when the URL is a syntactically valid Discord webhook endpoint. + Exposed so the LiveView form can validate before submitting. + """ + def valid_webhook_url?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: "https", host: host, path: path} when is_binary(host) and is_binary(path) -> + # Hostnames are case-insensitive and `URI.parse/1` returns the host + # exactly as typed, so a pasted "https://Discord.com/..." would + # otherwise be rejected as not-a-Discord-URL. + String.downcase(host) in @discord_hosts and valid_webhook_path?(path) + + _ -> + false + end + end + + def valid_webhook_url?(_), do: false + + defp valid_webhook_path?(path) do + case String.split(path, "/", trim: true) do + ["api", "webhooks", id, token] -> + id != "" and token != "" + + ["api", version, "webhooks", id, token] -> + String.starts_with?(version, "v") and id != "" and token != "" + + _ -> + false + end + end + + defmodule ValidateWebhookUrl do + @moduledoc false + use Ash.Resource.Validation + + @impl true + def validate(changeset, _opts, _context) do + # AshCloak rewrites the encrypted field into a changeset *argument* (the + # stored attribute is `encrypted_webhook_url`, and `webhook_url` becomes a + # calculation). Reading only the attribute yields `%Ash.NotLoaded{}` โ€” not + # nil โ€” which fails every validity check and rejects even valid URLs. + # Read the argument first so the value being written is what gets checked. + case Ash.Changeset.get_argument_or_attribute(changeset, :webhook_url) do + nil -> + :ok + + url -> + if WandererApp.Api.MapDiscordWebhook.valid_webhook_url?(url) do + :ok + else + {:error, + field: :webhook_url, + message: + "must be a Discord webhook URL, e.g. https://discord.com/api/webhooks/{id}/{token}"} + end + end + end + end + + defmodule ValidateMentionTargets do + @moduledoc false + use Ash.Resource.Validation + + alias WandererApp.ExternalEvents.Discord.Mentions + + @impl true + def validate(changeset, _opts, _context) do + case Ash.Changeset.get_argument_or_attribute(changeset, :mention_targets) do + nil -> + :ok + + targets when is_list(targets) -> + if Enum.all?(targets, &Mentions.valid_target?/1) do + :ok + else + {:error, + field: :mention_targets, + message: "each entry must match user: or role: (17-20 digit snowflake)"} + end + + _ -> + :ok + end + end + end + + # after_transaction, not after_action: an after_action hook evicts the cached + # config while this row is still uncommitted, so a killmail arriving in that + # window reloads pre-commit state and re-caches it โ€” the old URL on an update, + # or the negative `:none` marker if the parent was created in the same + # transaction. Either sticks for the cache's 5-minute TTL. On rollback the + # error result passes straight through: there is nothing to invalidate, and + # evicting anyway would only discard a still-correct entry. + @doc false + def invalidate_cache(_changeset, {:ok, record}, _context) do + do_invalidate(record) + {:ok, record} + end + + def invalidate_cache(_changeset, other, _context), do: other + + @doc false + def after_destroy(_changeset, {:ok, record}, _context) do + do_invalidate(record) + # Stop this destination's delivery worker too: without this, anything already + # queued keeps posting to a webhook the user has just removed. + WandererApp.ExternalEvents.Discord.WorkerSupervisor.stop_worker(record.id) + {:ok, record} + end + + def after_destroy(_changeset, other, _context), do: other + + defp do_invalidate(record) do + case Ash.get(WandererApp.Api.MapDiscordNotification, record.notification_id) do + {:ok, notification} -> + WandererApp.ExternalEvents.DiscordDispatcher.invalidate_cache(notification.map_id) + + error -> + # Swallowed rather than raised โ€” a failed eviction must not fail the + # write that already committed โ€” but logged, because the consequence is + # a routing cache that serves the previous destination for the rest of + # the TTL, which is otherwise indistinguishable from "the user's change + # did nothing". + Logger.warning( + "[MapDiscordWebhook] cache not invalidated for webhook #{record.id}: #{inspect(error)}" + ) + + :ok + end + rescue + exception -> + Logger.warning( + "[MapDiscordWebhook] cache invalidation raised for webhook #{record.id}: " <> + Exception.message(exception) + ) + + :ok + end +end diff --git a/lib/wanderer_app/api/map_solar_system.ex b/lib/wanderer_app/api/map_solar_system.ex index 3eeab6110..ef2be23de 100644 --- a/lib/wanderer_app/api/map_solar_system.ex +++ b/lib/wanderer_app/api/map_solar_system.ex @@ -39,6 +39,7 @@ defmodule WandererApp.Api.MapSolarSystem do ) define(:find_by_name, action: :find_by_name) + define(:by_solar_system_ids, action: :by_solar_system_ids, args: [:solar_system_ids]) define(:get_wh_class_a, action: :get_wh_class_a) define(:get_trig_systems, action: :get_trig_systems) end @@ -103,6 +104,14 @@ defmodule WandererApp.Api.MapSolarSystem do filter(expr(contains(solar_system_name_lc, string_downcase(^arg(:name))))) end + # Batch lookup, so callers rendering a list of system ids resolve every name + # in one query instead of one per id. + read :by_solar_system_ids do + argument(:solar_system_ids, {:array, :integer}, allow_nil?: false) + + filter(expr(solar_system_id in ^arg(:solar_system_ids))) + end + read :get_wh_class_a do filter(expr(system_class == 1)) end diff --git a/lib/wanderer_app/api/map_system.ex b/lib/wanderer_app/api/map_system.ex index 0ca99f993..3b0d2dce3 100644 --- a/lib/wanderer_app/api/map_system.ex +++ b/lib/wanderer_app/api/map_system.ex @@ -2,24 +2,31 @@ defmodule WandererApp.Api.MapSystem do @moduledoc false @derive {Jason.Encoder, - only: [ - :id, - :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 - ]} + only: [ + :id, + :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, + # Zoo-specific fields โ€” present in the create/update accept list + # and consumed by the frontend, so they must survive serialization + # too. + :owner_id, + :owner_type, + :owner_ticker, + :custom_flags + ]} use Ash.Resource, domain: WandererApp.Api, @@ -30,6 +37,12 @@ defmodule WandererApp.Api.MapSystem do postgres do repo(WandererApp.Repo) table("map_system_v1") + + custom_indexes do + # Performance index for filtering visible systems by map + # Added in upstream migration 20251108142542 + index [:map_id], name: "map_system_v1_map_id_visible_index", where: "visible = true" + end end json_api do @@ -45,7 +58,11 @@ defmodule WandererApp.Api.MapSystem do :description, :tag, :temporary_name, - :labels + :labels, + :owner_id, + :owner_type, + :owner_ticker, + :custom_flags ]) derive_filter?(true) @@ -97,10 +114,15 @@ defmodule WandererApp.Api.MapSystem do define(:update_tag, action: :update_tag) define(:update_temporary_name, action: :update_temporary_name) define(:update_custom_name, action: :update_custom_name) + define(:update_owner, action: :update_owner) + define(:update_owner_id, action: :update_owner_id) + define(:update_owner_type, action: :update_owner_type) define(:update_labels, action: :update_labels) define(:update_linked_sig_eve_id, action: :update_linked_sig_eve_id) define(:update_position, action: :update_position) define(:update_visible, action: :update_visible) + define(:update_intel, action: :update_intel) + define(:update_custom_flags, action: :update_custom_flags) end actions do @@ -140,7 +162,12 @@ defmodule WandererApp.Api.MapSystem do :temporary_name, :labels, :added_at, - :linked_sig_eve_id + :linked_sig_eve_id, + # Zoo-specific fields (for map duplication) + :owner_id, + :owner_type, + :owner_ticker, + :custom_flags ] # Inject map_id from token @@ -270,6 +297,26 @@ defmodule WandererApp.Api.MapSystem do require_atomic? false end + update :update_owner do + accept [:owner_id, :owner_type, :owner_ticker] + require_atomic? false + end + + update :update_owner_id do + accept [:owner_id] + require_atomic? false + end + + update :update_owner_type do + accept [:owner_type] + require_atomic? false + end + + update :update_custom_flags do + accept [:custom_flags] + require_atomic? false + end + update :update_labels do accept [:labels] require_atomic? false @@ -291,6 +338,11 @@ defmodule WandererApp.Api.MapSystem do accept [:visible] require_atomic? false end + + update :update_intel do + accept [:custom_name, :description, :tag, :temporary_name, :labels, :status] + require_atomic? false + end end attributes do @@ -321,6 +373,22 @@ defmodule WandererApp.Api.MapSystem do allow_nil? true end + attribute :owner_id, :string do + allow_nil? true + end + + attribute :owner_type, :string do + allow_nil? true + end + + attribute :owner_ticker, :string do + allow_nil? true + end + + attribute :custom_flags, :string do + allow_nil? true + end + attribute :labels, :string do allow_nil? true end diff --git a/lib/wanderer_app/api/map_system_comment.ex b/lib/wanderer_app/api/map_system_comment.ex index f5d5a997b..42dbc48e8 100644 --- a/lib/wanderer_app/api/map_system_comment.ex +++ b/lib/wanderer_app/api/map_system_comment.ex @@ -37,6 +37,7 @@ defmodule WandererApp.Api.MapSystemComment do code_interface do define(:create, action: :create) define(:destroy, action: :destroy) + define(:inherited_by_system, action: :inherited_by_system, args: [:system_id, :source_map_id]) define(:by_id, get_by: [:id], @@ -61,7 +62,8 @@ defmodule WandererApp.Api.MapSystemComment do accept [ :system_id, :character_id, - :text + :text, + :inherited_from_map_id ] end @@ -70,6 +72,15 @@ defmodule WandererApp.Api.MapSystemComment do filter(expr(system_id == ^arg(:system_id))) end + + read :inherited_by_system do + argument(:system_id, :string, allow_nil?: false) + argument(:source_map_id, :uuid, allow_nil?: false) + + filter( + expr(system_id == ^arg(:system_id) and inherited_from_map_id == ^arg(:source_map_id)) + ) + end end attributes do @@ -94,5 +105,11 @@ defmodule WandererApp.Api.MapSystemComment do attribute_writable? true public? true end + + belongs_to :inherited_from_map, WandererApp.Api.Map do + attribute_writable? true + public? true + allow_nil? true + end end end diff --git a/lib/wanderer_app/api/map_system_structure.ex b/lib/wanderer_app/api/map_system_structure.ex index c4683c768..f8675e135 100644 --- a/lib/wanderer_app/api/map_system_structure.ex +++ b/lib/wanderer_app/api/map_system_structure.ex @@ -20,6 +20,7 @@ defmodule WandererApp.Api.MapSystemStructure do :owner_id, :status, :end_time, + :inherited_from_map_id, :inserted_at, :updated_at ]} @@ -79,6 +80,7 @@ defmodule WandererApp.Api.MapSystemStructure do define(:all_active, action: :all_active) define(:create, action: :create) define(:update, action: :update) + define(:inherited_by_system, action: :inherited_by_system, args: [:system_id, :source_map_id]) define(:by_id, get_by: [:id], @@ -89,6 +91,8 @@ defmodule WandererApp.Api.MapSystemStructure do action: :by_system_id, args: [:system_id] ) + + define(:destroy, action: :destroy) end actions do @@ -135,10 +139,20 @@ defmodule WandererApp.Api.MapSystemStructure do :owner_ticker, :owner_id, :status, - :end_time + :end_time, + :inherited_from_map_id ] end + read :inherited_by_system do + argument(:system_id, :string, allow_nil?: false) + argument(:source_map_id, :uuid, allow_nil?: false) + + filter( + expr(system_id == ^arg(:system_id) and inherited_from_map_id == ^arg(:source_map_id)) + ) + end + update :update do primary? true require_atomic? false @@ -233,5 +247,11 @@ defmodule WandererApp.Api.MapSystemStructure do attribute_writable? true public? true end + + belongs_to :inherited_from_map, WandererApp.Api.Map do + attribute_writable? true + public? true + allow_nil? true + end end end diff --git a/lib/wanderer_app/api/map_user_settings.ex b/lib/wanderer_app/api/map_user_settings.ex index f9c1b1fef..e2acda1bc 100644 --- a/lib/wanderer_app/api/map_user_settings.ex +++ b/lib/wanderer_app/api/map_user_settings.ex @@ -48,9 +48,12 @@ defmodule WandererApp.Api.MapUserSettings do ) define(:update_hubs, action: :update_hubs) + define(:read_by_map, action: :read_by_map) + define(:read_by_ready_character, action: :read_by_ready_character) + define(:update_settings, action: :update_settings) define(:update_following_character, action: :update_following_character) - define(:update_main_character, action: :update_main_character) + define(:update_ready_characters, action: :update_ready_characters) end actions do @@ -66,6 +69,20 @@ defmodule WandererApp.Api.MapUserSettings do require_atomic? false end + read :read_by_map do + argument(:map_id, :string, allow_nil?: false) + filter(expr(map_id == ^arg(:map_id))) + end + + # Array containment has to go through a fragment, but it still belongs in an + # Ash action: the repo previously hand-rolled an Ecto query against + # `map_user_settings_v1` and rebuilt partial structs from the result. + read :read_by_ready_character do + argument(:character_eve_id, :string, allow_nil?: false) + + filter(expr(fragment("? = ANY(?)", ^arg(:character_eve_id), ready_characters))) + end + update :update_settings do accept [:settings] require_atomic? false @@ -81,6 +98,25 @@ defmodule WandererApp.Api.MapUserSettings do require_atomic? false end + update :update_ready_characters do + accept [:ready_characters] + + # `MapUserSettingsRepo.ready_character_eve_ids/1` caches the map-wide + # ready set that every connected LiveView reads on each + # `characters_updated` broadcast. Invalidating here rather than at the + # four call sites means a new write path cannot forget to. + # + # `after_transaction`, not `after_action`: an after_action hook fires + # while the UPDATE is still uncommitted, so a broadcast arriving in that + # window reloads the pre-commit rows and re-caches the stale set for the + # full TTL โ€” the ready flag the user just toggled would appear to revert + # for five minutes. On rollback the error passes through untouched, so a + # failed write cannot evict a still-correct entry. + require_atomic? false + + change after_transaction(&__MODULE__.invalidate_ready_cache/3) + end + update :update_hubs do accept [:hubs] require_atomic? false @@ -105,6 +141,11 @@ defmodule WandererApp.Api.MapUserSettings do public? true end + attribute :ready_characters, {:array, :string} do + allow_nil? true + default([]) + end + attribute :hubs, {:array, :string} do allow_nil?(true) public? true @@ -120,4 +161,12 @@ defmodule WandererApp.Api.MapUserSettings do identities do identity :uniq_map_user, [:map_id, :user_id] end + + @doc false + def invalidate_ready_cache(_changeset, {:ok, record}, _context) do + WandererApp.MapUserSettingsRepo.invalidate_ready_character_eve_ids(record.map_id) + {:ok, record} + end + + def invalidate_ready_cache(_changeset, other, _context), do: other end diff --git a/lib/wanderer_app/application.ex b/lib/wanderer_app/application.ex index 0d216b682..04f8584a2 100644 --- a/lib/wanderer_app/application.ex +++ b/lib/wanderer_app/application.ex @@ -5,6 +5,9 @@ defmodule WandererApp.Application do require Logger + # Mirrors the WANDERER_DISCORD_POOL_SIZE default in config/runtime.exs. + @default_discord_pool_size 10 + @impl true def start(_type, _args) do # Skip test mocks setup - handled in test helper if needed @@ -50,6 +53,31 @@ defmodule WandererApp.Application do ] } }, + # Discord pool - isolated so a slow Discord cannot exhaust shared pools + { + Finch, + name: WandererApp.Finch.Discord, + pools: %{ + default: [ + size: discord_pool_size(), + count: 1 + ] + } + }, + # Triff pool - isolated so a slow market API cannot exhaust the ESI or + # Discord pools. Sized from app env with the same defaults as the webhooks + # pool; no dedicated env var, since the request rate is bounded by the + # dispatcher's enrichment budget. + { + Finch, + name: WandererApp.Finch.Triff, + pools: %{ + default: [ + size: Application.get_env(:wanderer_app, :finch_triff_pool_size, 25), + count: Application.get_env(:wanderer_app, :finch_triff_pool_count, 2) + ] + } + }, # Default pool - everything else (email, license manager, etc.) { Finch, @@ -62,63 +90,82 @@ defmodule WandererApp.Application do } }, WandererApp.Cache, - Supervisor.child_spec({Cachex, name: :api_cache, default_ttl: :timer.hours(1)}, - id: :api_cache_worker - ), - Supervisor.child_spec( - {Cachex, name: :esi_auth_cache, default_ttl: :timer.minutes(30)}, - id: :esi_auth_cache_worker - ), - Supervisor.child_spec( - {Cachex, name: :system_static_info_cache, default_ttl: :timer.hours(4)}, + # NONE of the caches below expire anything. + # + # Each of these specs carried a `default_ttl:` option. That option is + # Cachex 2.x; this project is on Cachex 3.6, where the equivalent is + # `expiration: expiration(default: ...)`. Cachex 3 ignores options it does + # not recognise, so every one of those TTLs was silently discarded at + # startup and every entry has lived until the process died. The options + # are removed rather than translated because turning real expiration on + # across fifteen caches at once is a behaviour change that deserves to be + # made deliberately, per cache, with the eviction consequences considered + # โ€” not smuggled in as a "fix" to a line that has never done anything. + # + # The TTLs that were intended, kept here so restoring one is a decision + # and not an archaeology exercise: + # + # :api_cache 1h :map_state_cache 2h + # :esi_auth_cache 30m :character_state_cache 1h + # :system_static_info_cache 4h :tracked_characters 1h + # :ship_types_cache 4h :wanderer_app_cache 1h + # :character_cache 1h :webhook_subscriptions_cache 5m + # :acl_cache 1h :discord_notification_cache 5m + # :map_cache 2h :discord_dedup_cache 24h + # :map_pool_cache 2h + # + # Per-entry TTLs passed explicitly to `Cachex.put/4` were never affected + # and still work; only the per-cache default was dead. + Supervisor.child_spec({Cachex, name: :api_cache}, id: :api_cache_worker), + Supervisor.child_spec({Cachex, name: :esi_auth_cache}, id: :esi_auth_cache_worker), + Supervisor.child_spec({Cachex, name: :system_static_info_cache}, id: :system_static_info_cache_worker ), - Supervisor.child_spec( - {Cachex, name: :ship_types_cache, default_ttl: :timer.hours(4)}, - id: :ship_types_cache_worker - ), - Supervisor.child_spec( - {Cachex, name: :character_cache, default_ttl: :timer.hours(1)}, - id: :character_cache_worker - ), - Supervisor.child_spec( - {Cachex, name: :acl_cache, default_ttl: :timer.hours(1)}, - id: :acl_cache_worker - ), - Supervisor.child_spec( - {Cachex, name: :map_cache, default_ttl: :timer.hours(2)}, - id: :map_cache_worker - ), - Supervisor.child_spec( - {Cachex, name: :map_pool_cache, default_ttl: :timer.hours(2)}, - id: :map_pool_cache_worker - ), - Supervisor.child_spec( - {Cachex, name: :map_state_cache, default_ttl: :timer.hours(2)}, - id: :map_state_cache_worker - ), - Supervisor.child_spec( - {Cachex, name: :character_state_cache, default_ttl: :timer.hours(1)}, + Supervisor.child_spec({Cachex, name: :ship_types_cache}, id: :ship_types_cache_worker), + Supervisor.child_spec({Cachex, name: :character_cache}, id: :character_cache_worker), + Supervisor.child_spec({Cachex, name: :acl_cache}, id: :acl_cache_worker), + Supervisor.child_spec({Cachex, name: :map_cache}, id: :map_cache_worker), + Supervisor.child_spec({Cachex, name: :map_pool_cache}, id: :map_pool_cache_worker), + Supervisor.child_spec({Cachex, name: :map_state_cache}, id: :map_state_cache_worker), + Supervisor.child_spec({Cachex, name: :character_state_cache}, id: :character_state_cache_worker ), - Supervisor.child_spec( - {Cachex, name: :tracked_characters, default_ttl: :timer.hours(1)}, + Supervisor.child_spec({Cachex, name: :tracked_characters}, id: :tracked_characters_cache_worker ), - Supervisor.child_spec( - {Cachex, name: :wanderer_app_cache, default_ttl: :timer.hours(1)}, + Supervisor.child_spec({Cachex, name: :wanderer_app_cache}, id: :wanderer_app_cache_worker ), - # Cache for webhook subscriptions - 5 minute TTL to reduce DB load - Supervisor.child_spec( - {Cachex, name: :webhook_subscriptions_cache, default_ttl: :timer.minutes(5)}, + Supervisor.child_spec({Cachex, name: :webhook_subscriptions_cache}, id: :webhook_subscriptions_cache_worker ), + Supervisor.child_spec({Cachex, name: :discord_notification_cache}, + id: :discord_notification_cache_worker + ), + # Dedup marks for {map_id, killmail_id}. + Supervisor.child_spec({Cachex, name: :discord_dedup_cache}, + id: :discord_dedup_cache_worker + ), + # Route-alert state per map (route_state + config_version) โ€” no TTL: + # a route that is still open must not silently forget it was already + # announced just because a quiet period outlasted an expiry window. + Supervisor.child_spec( + {Cachex, name: :discord_route_alert_cache}, + id: :discord_route_alert_cache_worker + ), + # Started unconditionally (like the Discord caches above and + # TrackerRegistry below) rather than gated behind + # `maybe_start_external_events_services/0`: `Discord.RouteWatcher` + # processes are started individually by `RouteWatcherSupervisor`, not by + # that gate, and its own tests start a bare `RouteWatcher` without going + # through that supervisor at all. + {Registry, keys: :unique, name: WandererApp.ExternalEvents.Discord.RouteWatcherRegistry}, {Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry}, {PartitionSupervisor, child_spec: DynamicSupervisor, name: WandererApp.Character.DynamicSupervisors}, WandererAppWeb.PresenceGracePeriodManager, WandererAppWeb.Presence, + {Task.Supervisor, name: WandererApp.TaskSupervisor}, WandererAppWeb.Endpoint ] @@ -223,36 +270,56 @@ defmodule WandererApp.Application do sse_enabled = WandererApp.Env.sse_enabled?() webhooks_enabled = external_events_config[:webhooks_enabled] || false - services = [] - - # Always include MapEventRelay if any external events are enabled - services = - if sse_enabled || webhooks_enabled do - Logger.info("Starting external events system...") - [WandererApp.ExternalEvents.MapEventRelay | services] - else - services - end - - # Add WebhookDispatcher if webhooks are enabled - services = + webhook_services = if webhooks_enabled do Logger.info("Starting webhook dispatcher...") - [WandererApp.ExternalEvents.WebhookDispatcher | services] + + [ + WandererApp.ExternalEvents.WebhookDispatcher, + # Boot-side-effect only (returns :ignore): starts the Nostrum gateway + # when voice mentions are configured. Before the worker tree so the + # voice cache starts warming as early as possible. + WandererApp.ExternalEvents.Discord.VoiceGateway, + # Supervisor before the dispatcher that routes work into it, so the + # first event does not find the worker tree missing. + WandererApp.ExternalEvents.Discord.WorkerSupervisor, + # Route-alert watchers post through WorkerSupervisor, so this + # comes after it. Before DiscordDispatcher, whose new topology + # clause (Task 9) calls RouteWatcherSupervisor.notify/1 and must + # never find the tree missing. + WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor, + WandererApp.ExternalEvents.DiscordDispatcher + ] else - services + [] end - # Add SseStreamManager if SSE is enabled - services = + sse_services = if sse_enabled do Logger.info("Starting SSE stream manager...") - [WandererApp.ExternalEvents.SseStreamManager | services] + [WandererApp.ExternalEvents.SseStreamManager] else - services + [] end - Enum.reverse(services) + relay = + if sse_enabled || webhooks_enabled do + Logger.info("Starting external events system...") + # Started last: it produces the events every service above consumes, + # and dispatch is a cast, so anything emitted before its consumers are + # registered is silently dropped. + [WandererApp.ExternalEvents.MapEventRelay] + else + [] + end + + webhook_services ++ sse_services ++ relay end end + + defp discord_pool_size do + :wanderer_app + |> Application.get_env(:discord_finch, []) + |> Keyword.get(:pool_size, @default_discord_pool_size) + end end diff --git a/lib/wanderer_app/cached_info.ex b/lib/wanderer_app/cached_info.ex index a50e9e3ca..a468e39df 100644 --- a/lib/wanderer_app/cached_info.ex +++ b/lib/wanderer_app/cached_info.ex @@ -3,6 +3,32 @@ defmodule WandererApp.CachedInfo do alias WandererAppWeb.Helpers.APIUtils + # Courier lock key for the full-table warm-up in `warm_system_static_info_cache/0`. + # It is never committed to the cache (the fallback returns `{:ignore, _}`), so + # it cannot collide with the integer solar system ids stored alongside it. + @static_info_warm_key :__system_static_info_warm__ + + @system_static_info_attrs [ + :solar_system_id, + :region_id, + :constellation_id, + :solar_system_name, + :solar_system_name_lc, + :constellation_name, + :region_name, + :system_class, + :security, + :type_description, + :class_title, + :is_shattered, + :effect_name, + :effect_power, + :statics, + :wandering, + :triglavian_invasion_status, + :sun_type_id + ] + def run(_arg) do :ok = cache_trig_systems() end @@ -100,44 +126,15 @@ defmodule WandererApp.CachedInfo do case Cachex.get(:system_static_info_cache, solar_system_id) do {:ok, nil} -> - case WandererApp.Api.MapSolarSystem.read() do - {:ok, systems} -> - systems - |> Enum.each(fn system -> - Cachex.put( - :system_static_info_cache, - system.solar_system_id, - Map.take(system, [ - :solar_system_id, - :region_id, - :constellation_id, - :solar_system_name, - :solar_system_name_lc, - :constellation_name, - :region_name, - :system_class, - :security, - :type_description, - :class_title, - :is_shattered, - :effect_name, - :effect_power, - :statics, - :wandering, - :triglavian_invasion_status, - :sun_type_id - ]) - ) - end) - + case warm_system_static_info_cache() do + :ok -> case Cachex.get(:system_static_info_cache, solar_system_id) do {:ok, nil} -> {:error, :not_found} result -> result end {:error, reason} -> - Logger.error("Failed to read solar systems from API: #{inspect(reason)}") - {:error, :api_error} + {:error, reason} end {:ok, system_static_info} -> @@ -149,6 +146,78 @@ defmodule WandererApp.CachedInfo do end end + # A single cache miss repopulates the WHOLE table, so N concurrent misses used + # to run N full `MapSolarSystem.read/0` scans plus N row-by-row rewrites of the + # same data. That is the normal case, not an edge case: `:system_static_info_cache` + # has a 4h TTL (application.ex) and route hydration fans out over every system + # in a route at `schedulers_online() * 4` concurrency, so every restart and + # every TTL rollover produced a stampede โ€” and each of those scans is what made + # a single lookup slow enough to blow a `Task.async_stream` timeout. + # + # `Cachex.fetch/4` routes the fallback through Cachex's Courier, which runs it + # at most once at a time per key and hands every concurrent caller the same + # result. Returning `{:ignore, _}` means the marker key is never written, so + # sequential behaviour is unchanged: a later miss still triggers a fresh scan + # and still picks up a newly inserted system immediately. Only *simultaneous* + # scans are collapsed. + defp warm_system_static_info_cache do + # Cachex runs the fallback in a Courier-owned process, not the caller's, and + # that process starts with an empty dictionary. `$callers` has to be carried + # across by hand or the read loses the caller's context โ€” most visibly under + # `Ecto.Adapters.SQL.Sandbox` ownership mode, where an unlinked process has + # no checked-out connection and every warm-up would fail with + # `DBConnection.OwnershipError`. + callers = [self() | Process.get(:"$callers", [])] + + result = + Cachex.fetch(:system_static_info_cache, @static_info_warm_key, fn _key -> + Process.put(:"$callers", callers) + + case WandererApp.Api.MapSolarSystem.read() do + {:ok, systems} -> + # A discarded write is worse here than anywhere else: the caller + # re-reads the key straight after this returns, so a silently failed + # put surfaces as `{:error, :not_found}` โ€” "this system does not + # exist" โ€” for a system that does. Halt on the first failure rather + # than grinding through thousands more writes into a cache that has + # already told us it is not accepting them. + Enum.reduce_while(systems, {:ignore, :ok}, fn system, acc -> + case Cachex.put( + :system_static_info_cache, + system.solar_system_id, + Map.take(system, @system_static_info_attrs) + ) do + {:ok, true} -> + {:cont, acc} + + error -> + Logger.error( + "Failed to cache static info for solar system " <> + "#{system.solar_system_id}: #{inspect(error)}" + ) + + {:halt, {:ignore, {:error, :cache_error}}} + end + end) + + {:error, reason} -> + Logger.error("Failed to read solar systems from API: #{inspect(reason)}") + {:ignore, {:error, :api_error}} + end + end) + + case result do + {:ignore, outcome} -> + outcome + + # Defensive: `{:ignore, _}` above is the only path that can populate this, + # so anything else means Cachex itself failed (dead cache, etc.). + {:error, reason} -> + Logger.error("Failed to warm system static info cache: #{inspect(reason)}") + {:error, :cache_error} + end + end + def get_system_static_info!(solar_system_id) do case get_system_static_info(solar_system_id) do {:ok, system_static_info} -> diff --git a/lib/wanderer_app/character.ex b/lib/wanderer_app/character.ex index caab0d63d..6d390eac9 100644 --- a/lib/wanderer_app/character.ex +++ b/lib/wanderer_app/character.ex @@ -173,30 +173,61 @@ defmodule WandererApp.Character do :ok end + @doc """ + Searches EVE entities via ESI as `character_id`. + + Returns `{:ok, results}` on success and `{:error, reason}` when the lookup + could not be performed โ€” a missing character, or an ESI call that failed or + timed out. + + A failed lookup deliberately does NOT collapse into `{:ok, []}`. Every caller + is a typeahead, and "ESI refused this request" rendered as an empty dropdown is + indistinguishable from "no such corporation": the user retypes the name they + know is correct and concludes the feature is broken. Callers that cannot act on + the reason should still degrade explicitly rather than by accident. + + One gap remains by design: the per-hit detail lookups that turn ESI ids into + labels are best-effort, so a hit whose detail lookup fails is dropped from an + otherwise successful `{:ok, results}` rather than failing the search. Those + drops are logged. + """ def search(character_id, opts \\ []) do - get_character(character_id) - |> case do - {:ok, %{access_token: access_token, eve_id: eve_id} = _character} -> - case WandererApp.Esi.search(eve_id |> String.to_integer(), - access_token: access_token, - character_id: character_id, - refresh_token?: true, - params: opts[:params] - ) do + case get_character(character_id) do + {:ok, %{access_token: access_token, eve_id: eve_id} = character} -> + WandererApp.Esi.search(eve_id |> String.to_integer(), + access_token: access_token, + character_id: character_id, + refresh_token?: true, + params: opts[:params] + ) + |> case do {:ok, result} -> - {:ok, result |> prepare_search_results()} + {:ok, prepare_search_results(result)} + + {:error, reason} -> + # The character is named because the caller ran this as one specific + # character out of possibly several, and the reason alone cannot tell + # an operator which token is the stale one. + Logger.warning( + "#{__MODULE__} failed search as #{Map.get(character, :name)} (#{eve_id}): #{inspect(reason)}" + ) - {:error, error} -> - Logger.warning("#{__MODULE__} failed search: #{inspect(error)}") - {:ok, []} + {:error, reason} - error -> - Logger.warning("#{__MODULE__} failed search: #{inspect(error)}") - {:ok, []} + other -> + Logger.warning( + "#{__MODULE__} failed search as #{Map.get(character, :name)} (#{eve_id}): #{inspect(other)}" + ) + + {:error, other} end - _error -> - {:ok, []} + other -> + Logger.warning( + "#{__MODULE__} search could not resolve character #{inspect(character_id)}: #{inspect(other)}" + ) + + {:error, :character_not_found} end end @@ -327,21 +358,36 @@ defmodule WandererApp.Character do defp load_eve_info([], _, _), do: {:ok, []} - defp load_eve_info(eve_ids, method, map_function), - do: - {:ok, - Enum.map(eve_ids, fn eve_id -> - Task.async(fn -> apply(WandererApp.Esi, method, [eve_id]) end) - end) - # 145000 == Timeout in milliseconds - |> Enum.map(fn task -> Task.await(task, 145_000) end) - |> Enum.map(fn result -> - case result do - {:ok, result} -> map_function.(result) - _ -> nil - end - end) - |> Enum.filter(fn result -> not is_nil(result) end)} + defp load_eve_info(eve_ids, method, map_function) do + results = + eve_ids + |> Enum.map(fn eve_id -> + Task.async(fn -> apply(WandererApp.Esi, method, [eve_id]) end) + end) + # 145000 == Timeout in milliseconds + |> Enum.map(fn task -> Task.await(task, 145_000) end) + |> Enum.map(fn result -> + case result do + {:ok, result} -> map_function.(result) + _ -> nil + end + end) + + # A hit whose detail lookup failed is dropped rather than failing the whole + # search: partial results still let the user pick the entity they wanted. + # Logged in aggregate (not per id) so an ESI outage costs one line per search + # rather than one per hit โ€” without it, `search/2` answers `{:ok, []}` from a + # total detail-lookup failure and looks indistinguishable from "no matches". + dropped = Enum.count(results, &is_nil/1) + + if dropped > 0 do + Logger.warning( + "#{__MODULE__} #{method} dropped #{dropped}/#{length(eve_ids)} search hits with failed detail lookups" + ) + end + + {:ok, Enum.reject(results, &is_nil/1)} + end defp map_alliance_info(info) do %{ diff --git a/lib/wanderer_app/character/tracker.ex b/lib/wanderer_app/character/tracker.ex index bcdca3567..a65eed63e 100644 --- a/lib/wanderer_app/character/tracker.ex +++ b/lib/wanderer_app/character/tracker.ex @@ -17,6 +17,12 @@ defmodule WandererApp.Character.Tracker do track_location: false, track_ship: false, track_wallet: false, + # Which branch point last cleared track_location, so report_location_repair/1 + # can say what it is repairing. Nil means "no clear recorded in this tracker's + # lifetime" โ€” including every character whose clear predates a restart, since + # :character_state_cache is in-memory โ€” and is reported as :unknown rather + # than omitted, so the tag is always present in the series. + last_cleared_reason: nil, status: "new" ] @@ -33,6 +39,7 @@ defmodule WandererApp.Character.Tracker do track_location: boolean, track_ship: boolean, track_wallet: boolean, + last_cleared_reason: atom() | nil, status: binary() } @@ -83,6 +90,13 @@ defmodule WandererApp.Character.Tracker do WandererApp.Cache.delete("character:#{character_id}:last_online_time") + # The ESI handler clears ready status only when it observes the + # online->offline transition. This timeout path writes `online: false` + # directly, so without this call the character stayed marked ready + # forever โ€” and the next ESI poll skips cleanup because the state is + # already false. + clear_ready_status(character_id) + :ok else :skip @@ -90,6 +104,26 @@ defmodule WandererApp.Character.Tracker do end end + defp clear_ready_status(character_id) do + case WandererApp.Character.get_character(character_id) do + {:ok, character} -> + case WandererApp.Character.TrackingUtils.clear_ready_status_on_offline(character.eve_id) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning( + "Failed to clear ready status for character #{character.eve_id}: #{inspect(reason)}" + ) + end + + {:error, reason} -> + Logger.warning( + "Failed to get character #{character_id} for ready status clearing: #{inspect(reason)}" + ) + end + end + defp increment_location_error_count(character_id) do cache_key = "character:#{character_id}:location_error_count" current_count = WandererApp.Cache.lookup!(cache_key) || 0 @@ -183,6 +217,11 @@ defmodule WandererApp.Character.Tracker do reraise error, __STACKTRACE__ end + # Clear ready status if character went offline + if not online.online do + clear_ready_status(character_id) + end + try do WandererApp.Character.update_character_state(character_id, %{ character_state @@ -613,6 +652,60 @@ defmodule WandererApp.Character.Tracker do end end + # A character who is online and on at least one active map must have location + # polling enabled. After the maybe_start_location_tracking/2 fix this state + # should be unreachable, so log it loudly instead of returning + # {:error, :skipped} silently โ€” a silent skip here is exactly what hid this bug + # in production: the map kept the character in presence and polled it every + # second, but the tracker never fetched a location, so nothing looked wrong. + # + # Deliberately does not fire for offline characters; track_location is false + # for them by design and that is not a fault. + # + # Throttled to one line per character per minute; update_location/1 runs on a + # per-second tick. + def update_location( + %{ + track_location: false, + is_online: true, + character_id: character_id, + active_maps: [_ | _] = active_maps + } = _character_state + ) do + if WandererApp.Cache.put_new( + "character:#{character_id}:location_skip_logged", + true, + ttl: :timer.minutes(1) + ) do + Logger.warning( + "[Tracker] update_location skipped for online character #{character_id} while active " <> + "on #{length(active_maps)} map(s): track_location=false. The map believes this " <> + "character is tracked but its location will never update.", + character_id: character_id + ) + + # Emitted inside the throttle deliberately: update_location/1 runs on a + # per-second tick, so an unthrottled counter would measure ticks rather + # than incidents. One count here is roughly one character-minute stuck. + # + # This must stay at zero once the maybe_start_location_tracking/2 fix is + # deployed. Any nonzero value means a path to the frozen state that the + # fix does not cover. + # + # There is no legacy-stuck drain period to discount: :character_state_cache + # is in-memory (application.ex), so the deploy that ships this fix also + # clears every already-frozen character. A count on a post-deploy tracker + # is therefore a new incident, not a leftover one. + :telemetry.execute( + [:wanderer_app, :character, :tracking, :location_skipped_while_active], + %{count: 1, system_time: System.system_time()}, + %{character_id: character_id} + ) + end + + {:error, :skipped} + end + def update_location(_), do: {:error, :skipped} def update_wallet(character_id) do @@ -1004,24 +1097,97 @@ defmodule WandererApp.Character.Tracker do ), do: state + # Location and ship tracking follow map membership rather than an explicit flag. + # + # Every caller that starts map tracking sends only %{map_id: ..., track: true} + # (TrackingUtils.track_character/4 and both re-track paths in the map server's + # reconcile_tracking/1), so matching solely on an explicit `track_location` key + # left the flag false. The only other writer, update_online/1, fires on an + # online-status *transition*, so a character who was already online when map + # tracking began never got the flag set โ€” update_location/1 then fell through + # to its catch-all clause and the map never saw them move. + # + # Deriving from active_maps makes this symmetric with maybe_stop_tracking/2, + # which clears both flags once no maps remain active. These clauses run after + # maybe_update_active_maps/2 and maybe_stop_tracking/2 in the update_settings/2 + # pipeline, so active_maps is already accurate here. defp maybe_start_location_tracking( state, %{track_location: true} = _track_settings ), do: %{state | track_location: true} + defp maybe_start_location_tracking( + %{active_maps: [_ | _]} = state, + _track_settings + ) do + report_location_repair(state) + # last_cleared_reason is consumed here: the clear it described has now been + # undone, and leaving it set would let the *next* repair inherit a label + # belonging to an older clear. + %{state | track_location: true, last_cleared_reason: nil} + end + defp maybe_start_location_tracking( state, _track_settings ), do: state + # Emits only when maybe_start_location_tracking/2 is genuinely repairing the + # defect: the character is online in EVE but location tracking is off โ€” the + # pair that update_online/1 can never fix, because no online transition will + # ever occur. + # + # A freshly started tracker also reaches that clause with track_location: + # false, but with is_online: false, and that is the ordinary start path rather + # than a repair. Excluding it keeps this counter meaningful: every emission is + # a character who WOULD have frozen on the map before this fix. Pair with + # :location_flag_cleared (where the bad state is created) and + # :location_skipped_while_active (which must stay at zero). + # Tagged with the reason for the clear this repair undoes, because the two are + # not the same kind of event: repaired{reason: presence_driven} is the fix + # saving a character who would have frozen, while + # repaired{reason: manual_untrack} is the fix reverting an operator who + # deliberately pressed Untrack. Only the first belongs in the headline number. + # :unknown covers clears this tracker did not witness โ€” its state is held in an + # in-memory cache, so every restart resets the field. + defp report_location_repair(%{ + is_online: true, + track_location: false, + character_id: character_id, + last_cleared_reason: last_cleared_reason + }) do + reason = last_cleared_reason || :unknown + + Logger.info( + "[Tracker] Restored location tracking for online character #{character_id} on map " <> + "re-entry; before this fix the character would have stopped moving on the map. " <> + "cleared_reason=#{reason}", + character_id: character_id + ) + + :telemetry.execute( + [:wanderer_app, :character, :tracking, :location_flag_repaired], + %{count: 1, system_time: System.system_time()}, + %{character_id: character_id, reason: reason} + ) + end + + defp report_location_repair(_state), do: :ok + defp maybe_start_ship_tracking( state, %{track_ship: true} = _track_settings ), do: %{state | track_ship: true} + defp maybe_start_ship_tracking( + %{active_maps: [_ | _]} = state, + _track_settings + ), + do: %{state | track_ship: true} + defp maybe_start_ship_tracking( state, _track_settings @@ -1090,8 +1256,14 @@ defmodule WandererApp.Character.Tracker do do: state defp maybe_stop_tracking( - %{active_maps: [], character_id: character_id, opts: opts} = state, - _track_settings + %{ + active_maps: [], + character_id: character_id, + is_online: is_online, + track_location: track_location, + opts: opts + } = state, + track_settings ) do if is_nil(opts[:keep_alive]) do WandererApp.Cache.put( @@ -1100,7 +1272,34 @@ defmodule WandererApp.Character.Tracker do ) end - %{state | track_location: false, track_ship: false} + # Clearing track_location while the character is still online in EVE is what + # creates the (is_online: true, track_location: false) pair. update_online/1 + # only rewrites those fields on an online-status transition, so once this + # runs no transition is pending and the flag cannot come back on its own. + # This is the origin event for the freeze; correlate its timestamps with + # presence grace-period expiry to confirm the trigger. + # + # Guarded on the *prior* track_location so this counts transitions, not + # calls: a repeat untrack for an already-stopped character clears nothing, + # and counting it would inflate this metric relative to the + # :location_flag_repaired counter it is meant to be read against. + if is_online and track_location do + :telemetry.execute( + [:wanderer_app, :character, :tracking, :location_flag_cleared], + %{count: 1, system_time: System.system_time()}, + %{character_id: character_id, reason: untrack_reason(track_settings)} + ) + end + + # Recorded even when the counter above does not fire, so a later repair can + # still name its cause. Only overwritten on a real clear, so it always + # describes the clear that the next repair undoes. + %{ + state + | track_location: false, + track_ship: false, + last_cleared_reason: untrack_reason(track_settings) + } end defp maybe_stop_tracking( @@ -1109,6 +1308,27 @@ defmodule WandererApp.Character.Tracker do ), do: state + # The label set `location_flag_cleared` and `last_cleared_reason` are grouped + # by. Must stay in step with @untrack_reasons in prom_ex_plugin.ex, which + # declares exactly these four to Prometheus; an atom outside the list would + # create a series PromEx never declared. + @untrack_reasons [:presence_driven, :acl_revoked, :manual_untrack] + + # Callers that predate the bounded reason (and the internal re-entry paths in + # reconcile_tracking/1) send no untrack_reason at all; label those :unknown + # rather than guessing :presence_driven, which would quietly attribute them to + # the majority cause. + # + # An unrecognised atom degrades to :unknown rather than raising. The outer + # boundary โ€” CharactersImpl.untrack_characters/3 โ€” already raises on a bad + # reason, so anything arriving here has bypassed it; failing a character's + # tracker over a metric label would turn a cardinality slip into the freeze + # this instrumentation exists to detect. + defp untrack_reason(%{untrack_reason: reason}) when reason in @untrack_reasons, + do: reason + + defp untrack_reason(_track_settings), do: :unknown + defp get_location(%{ "solar_system_id" => solar_system_id, "station_id" => station_id diff --git a/lib/wanderer_app/character/tracker_manager_impl.ex b/lib/wanderer_app/character/tracker_manager_impl.ex index 9659ad8d3..d870f5a3b 100644 --- a/lib/wanderer_app/character/tracker_manager_impl.ex +++ b/lib/wanderer_app/character/tracker_manager_impl.ex @@ -154,6 +154,12 @@ defmodule WandererApp.Character.TrackerManager.Impl do end) remove_from_untrack_queue(map_id, character_id) + # The sidecar reason has to go with the queue entry it describes. A + # character untracked and re-tracked inside the drain window cancels the + # queue entry here; leaving the reason behind would both leak the key โ€” + # the drain that would have deleted it never runs โ€” and let a stale cause + # label an unrelated untrack later on. + WandererApp.Cache.delete(untrack_reason_key(map_id, character_id)) case WandererApp.Character.Tracker.update_settings(character_id, track_settings) do {:ok, character_state} -> @@ -178,12 +184,40 @@ defmodule WandererApp.Character.TrackerManager.Impl do "will be processed within #{div(@untrack_characters_interval, 60_000)} minutes" end) - add_to_untrack_queue(map_id, character_id) + add_to_untrack_queue(map_id, character_id, Map.get(track_settings, :untrack_reason)) end state end + # The queue itself stays a {map_id, character_id} tuple โ€” its uniqueness key + # and every existing reader depend on that shape. The reason rides alongside in + # its own cache key so a repeat untrack of the same pair simply overwrites it. + defp untrack_reason_key(map_id, character_id), + do: "character:#{character_id}:map:#{map_id}:untrack_reason" + + # Comfortably longer than the drain interval, so the reason is always present + # when its queue entry is processed, and short enough that a key orphaned by + # any path that empties the queue without draining it disappears on its own. + # An explicit ttl is required: this cache applies no default expiry. + @untrack_reason_ttl :timer.minutes(30) + + # Private on purpose: the reason is a metric label, and a public arity-3 entry + # point would be a second, unguarded way to set it. The only reasons that can + # reach here come through `CharactersImpl.untrack_characters/3`, whose guard + # already restricts them to :presence_driven, :acl_revoked or :manual_untrack. + # The arity-2 form stays public โ€” it takes no reason, so it cannot widen the + # label set, and an integration test calls it directly. + defp add_to_untrack_queue(map_id, character_id, reason) do + if not is_nil(reason) do + WandererApp.Cache.insert(untrack_reason_key(map_id, character_id), reason, + ttl: @untrack_reason_ttl + ) + end + + add_to_untrack_queue(map_id, character_id) + end + def add_to_untrack_queue(map_id, character_id) do WandererApp.Cache.insert_or_update( "character_untrack_queue", @@ -356,12 +390,21 @@ defmodule WandererApp.Character.TrackerManager.Impl do 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) + # Last writer wins, and the queue dedupes on {map_id, character_id} via + # Enum.uniq_by. So if two untracks with different causes land on the same + # pair inside one drain window, one queue entry survives and it carries + # the *later* cause. The untrack still happens exactly once and the label + # is still one of the real causes โ€” but if you are staring at a + # reason that does not match the log line you expected, this is why. + reason = WandererApp.Cache.lookup!(untrack_reason_key(map_id, character_id), :unknown) + + Logger.info( + "[TrackerManager] Untracking character #{character_id} from map #{map_id}, " <> + "reason=#{reason}" + ) remove_from_untrack_queue(map_id, character_id) + WandererApp.Cache.delete(untrack_reason_key(map_id, character_id)) WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:solar_system_id") WandererApp.Cache.delete("map:#{map_id}:character:#{character_id}:station_id") @@ -370,7 +413,8 @@ defmodule WandererApp.Character.TrackerManager.Impl do {:ok, character_state} = WandererApp.Character.Tracker.update_settings(character_id, %{ map_id: map_id, - track: false + track: false, + untrack_reason: reason }) {:ok, character} = WandererApp.Character.get_character(character_id) diff --git a/lib/wanderer_app/character/tracking_utils.ex b/lib/wanderer_app/character/tracking_utils.ex index ab8fb90f0..9bdab38eb 100644 --- a/lib/wanderer_app/character/tracking_utils.ex +++ b/lib/wanderer_app/character/tracking_utils.ex @@ -87,11 +87,21 @@ defmodule WandererApp.Character.TrackingUtils do %{eve_id: eve_id} -> eve_id end + # Get ready characters from user settings + ready_characters = + case user_settings do + nil -> [] + %{ready_characters: nil} -> [] + %{ready_characters: ready_chars} when is_list(ready_chars) -> ready_chars + _ -> [] + end + {:ok, %{ characters: characters_data, main: main_character_eve_id, - following: following_character_eve_id + following: following_character_eve_id, + ready_characters: ready_characters }} else nil -> @@ -421,6 +431,12 @@ defmodule WandererApp.Character.TrackingUtils do with false <- is_nil(caller_pid) do character_ids = characters |> Enum.map(& &1.id) + Logger.info( + "[TrackingUtils] Setting tracked=false in presence for #{length(character_ids)} characters " <> + "on map #{map_id}, character_ids=#{inspect(character_ids)}, " <> + "caller_pid=#{inspect(caller_pid)}, reason=presence_untrack_called" + ) + character_ids |> Enum.each(fn character_id -> WandererAppWeb.Presence.update(caller_pid, map_id, character_id, %{ @@ -465,4 +481,72 @@ defmodule WandererApp.Character.TrackingUtils do {:ok, current_user_characters |> Enum.find(fn c -> c.eve_id === main_character_eve_id end)} + + @doc """ + Clears ready status for a character across all maps when they go offline. + This ensures characters don't remain marked as ready when they're not online. + """ + def clear_ready_status_on_offline(character_eve_id) do + with {:ok, _character} <- WandererApp.Character.get_by_eve_id("#{character_eve_id}") do + # Get all map user settings that have this character marked as ready + case WandererApp.MapUserSettingsRepo.get_settings_with_ready_character(character_eve_id) do + {:ok, settings_list} -> + # Remove character from ready list in each setting + Enum.each(settings_list, fn user_settings -> + updated_ready_characters = + (user_settings.ready_characters || []) + |> List.delete(character_eve_id) + + case WandererApp.Api.MapUserSettings.update_ready_characters(user_settings, %{ + ready_characters: updated_ready_characters + }) do + {:ok, _updated_settings} -> + # Broadcast the change to other users in the map + broadcast_ready_status_cleared( + user_settings.map_id, + user_settings.user_id, + character_eve_id + ) + + {:error, reason} -> + Logger.error( + "Failed to clear ready status for character #{character_eve_id}: #{inspect(reason)}" + ) + end + end) + + :ok + + {:error, reason} -> + Logger.error( + "Failed to get settings for character #{character_eve_id}: #{inspect(reason)}" + ) + + {:error, reason} + end + else + {:error, reason} -> + Logger.error("Failed to get character #{character_eve_id}: #{inspect(reason)}") + {:error, reason} + end + end + + defp broadcast_ready_status_cleared(map_id, user_id, character_eve_id) do + # PubSub on the bare `map_id` topic โ€” the topic `MapCoreEventHandler` + # subscribes to. The previous `Endpoint.broadcast!("map:#{map_id}", ...)` + # published to a topic with no subscribers (this app defines no Phoenix + # channels), so a character going offline never cleared on anyone's screen. + # + # Reuses the `:ready_characters_updated` event the client already handles, + # carrying the map-wide ready set: the client applies the list to every + # character it holds, so a per-user list would clear other users' flags. + Phoenix.PubSub.broadcast!(WandererApp.PubSub, map_id, %{ + event: :ready_characters_updated, + payload: %{ + user_id: user_id, + cleared_character_eve_id: character_eve_id, + ready_character_eve_ids: WandererApp.MapUserSettingsRepo.ready_character_eve_ids(map_id) + } + }) + end end diff --git a/lib/wanderer_app/env.ex b/lib/wanderer_app/env.ex index f32fc9106..b501e740c 100644 --- a/lib/wanderer_app/env.ex +++ b/lib/wanderer_app/env.ex @@ -2,6 +2,8 @@ defmodule WandererApp.Env do @moduledoc false use Nebulex.Caching + require Logger + @app :wanderer_app @decorate cacheable( @@ -17,6 +19,7 @@ defmodule WandererApp.Env do def invites(), do: get_key(:invites, false) def map_subscriptions_enabled?(), do: get_key(:map_subscriptions_enabled, false) + def intel_sharing_enabled?(), do: get_key(:intel_sharing_enabled, false) def public_api_disabled?(), do: get_key(:public_api_disabled, false) @decorate cacheable( @@ -33,6 +36,7 @@ defmodule WandererApp.Env do def character_tracking_pause_disabled?(), do: get_key(:character_tracking_pause_disabled, true) def character_api_disabled?(), do: get_key(:character_api_disabled, false) def wanderer_kills_service_enabled?(), do: get_key(:wanderer_kills_service_enabled, false) + def wanderer_kills_ipv6?(), do: get_key(:wanderer_kills_ipv6, false) def wallet_tracking_enabled?(), do: get_key(:wallet_tracking_enabled, false) def admins(), do: get_key(:admins, []) def admin_username(), do: get_key(:admin_username) @@ -93,6 +97,278 @@ defmodule WandererApp.Env do |> Keyword.get(:webhooks_enabled, false) end + @default_discord_max_killmail_age_seconds 3600 + + @doc """ + Killmails older than this are dropped at the Discord dispatcher. + + Guards against an upstream replay burst on reconnect posting hours of history + into a chat channel. Deliberately not cached: it is read once per killmail + batch, and `Application.get_env/3` on a keyword list is cheaper than the cache + round trip. + + The declared contract is `pos_integer()`. A non-positive configured value is + a misconfiguration, not a valid setting. `kill_fresh?/3` keeps a kill when + `age <= max_age_seconds`, and a kill that has already happened always has a + non-negative age, so `0` drops every real killmail and a negative value is + stricter still โ€” it would keep only kills timestamped in the future (see + `WandererApp.ExternalEvents.DiscordDispatcher.kill_fresh?/3`). Both fail in + the same direction, silently suppressing every notification, and both are + otherwise invisible. So both fall back to the default with a loud warning + rather than being honoured. + """ + def discord_max_killmail_age_seconds() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_max_killmail_age_seconds, @default_discord_max_killmail_age_seconds) + |> validate_positive_integer( + :discord_max_killmail_age_seconds, + @default_discord_max_killmail_age_seconds + ) + end + + @default_discord_startup_grace_seconds 600 + @default_discord_startup_max_killmail_age_seconds 120 + + @doc """ + How long after the Discord dedup marks are lost the tighter startup maximum + age applies, in seconds. `0` disables the window. + + The marks live in `:discord_dedup_cache`, which is memory-only, so a restart + loses every one of them. The kills client then rejoins its channel and the + upstream service replays recent killmails, which the ordinary 3600-second + freshness limit happily admits โ€” an hour of already-posted kills, posted + again. During this window `discord_startup_max_killmail_age_seconds/0` + applies instead. + + Ten minutes rather than two because a long window is nearly free: it only + ever drops *old* killmails. The replay burst arrives when the kills client + joins the channel, which can be minutes after boot when the upstream service + is slow to accept the connection, and a short window would miss it. + + Validated as NON-NEGATIVE, unlike its sibling below. `0` is a legitimate + setting meaning "no startup window", and `validate_positive_integer/3` would + turn an operator's "disabled" into #{@default_discord_startup_grace_seconds} + seconds plus a warning โ€” the opposite of what they asked for. + """ + def discord_startup_grace_seconds() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_startup_grace_seconds, @default_discord_startup_grace_seconds) + |> validate_non_negative_integer( + :discord_startup_grace_seconds, + @default_discord_startup_grace_seconds + ) + end + + @doc """ + Maximum killmail age, in seconds, while the startup window is armed. + + Validated as POSITIVE, like `discord_max_killmail_age_seconds/0` and for the + same reason: a kill that has already happened always has a non-negative age + and the guard keeps a kill only when `age <= max`, so `0` or a negative value + would silently and invisibly suppress every notification. + + The accepted cost of the tighter limit is that a genuinely delayed killmail โ€” + upstream lag beyond this many seconds โ€” is dropped during the window. That is + the same trade the dispatcher already makes for at-most-once dedup: a dropped + kill stays visible in the kills widget and on zKillboard, while a duplicate + post in a chat channel is irreversible. + """ + def discord_startup_max_killmail_age_seconds() do + Application.get_env(@app, :external_events, []) + |> Keyword.get( + :discord_startup_max_killmail_age_seconds, + @default_discord_startup_max_killmail_age_seconds + ) + |> validate_positive_integer( + :discord_startup_max_killmail_age_seconds, + @default_discord_startup_max_killmail_age_seconds + ) + end + + @doc """ + Bot token for the voice-mention gateway connection, trimmed. `nil` when + unset, blank, or whitespace-only โ€” an unusable token must read as "not + configured", or `discord_voice_mentions_enabled?/0` would enable the + feature against a token Nostrum can never authenticate with. + """ + def discord_bot_token() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_bot_token) + |> normalize_token() + end + + defp normalize_token(token) when is_binary(token) do + case String.trim(token) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_token(_), do: nil + + @doc """ + Guild whose voice channels feed kill-notification mentions, as a positive + integer. `nil` when unset or malformed โ€” a malformed id disables the + feature; `VoiceGateway` warns once at boot rather than per kill. + """ + def discord_guild_id() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_guild_id) + |> parse_guild_id() + end + + defp parse_guild_id(nil), do: nil + defp parse_guild_id(id) when is_integer(id) and id > 0, do: id + defp parse_guild_id(id) when is_integer(id), do: nil + + defp parse_guild_id(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} when parsed > 0 -> parsed + _ -> nil + end + end + + defp parse_guild_id(_), do: nil + + @doc """ + Voice-participant mentions are on iff both the bot token and a valid guild + id are configured. Presence of config IS the feature flag (spec decision: + env vars only, no DB toggle). + """ + def discord_voice_mentions_enabled?() do + discord_bot_token() != nil and discord_guild_id() != nil + end + + @default_notable_items_threshold_isk 50_000_000 + @default_notable_items_limit 5 + @default_notable_items_timeout_ms 1_500 + + @doc """ + Whether Discord kill embeds carry a **Notable Items** section. + + Off by default: the section costs an ESI killmail fetch and a market lookup on + the dispatcher's critical path, so it is opt-in per deployment. + """ + def notable_items_enabled?() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:notable_items_enabled, false) + end + + @doc """ + Minimum ISK value for a dropped item to be worth naming. Matches + wanderer-notifier's threshold. + """ + def notable_items_threshold_isk() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:notable_items_threshold_isk, @default_notable_items_threshold_isk) + |> validate_positive_integer( + :notable_items_threshold_isk, + @default_notable_items_threshold_isk + ) + end + + @doc "Maximum items listed per kill. Matches wanderer-notifier's limit." + def notable_items_limit() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:notable_items_limit, @default_notable_items_limit) + |> validate_positive_integer(:notable_items_limit, @default_notable_items_limit) + end + + @doc """ + Hard ceiling on how long enrichment may block the singleton dispatcher. + + This budget is load-bearing โ€” see the concurrency notes in + `WandererApp.ExternalEvents.Discord.NotableItems`. Raising it stalls kill + notifications for every map on the instance. + """ + def notable_items_timeout_ms() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:notable_items_timeout_ms, @default_notable_items_timeout_ms) + |> validate_positive_integer(:notable_items_timeout_ms, @default_notable_items_timeout_ms) + end + + @default_corp_tickers_timeout_ms 1_500 + + @doc """ + Whether the Discord dispatcher fills in corporation tickers a killmail arrived + without. + + On by default, unlike `notable_items_enabled?/0`: an off switch here means + embeds silently lose the `(TICKER)` after each pilot name, which is a bug, not + a preference. It exists as a switch only so an operator can stop the ESI + lookups during an incident without waiting for a deploy โ€” the enrichment + already costs nothing on batches whose payload carried its tickers. + """ + def corp_tickers_enabled?() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:corp_tickers_enabled, true) + end + + @doc """ + Hard ceiling on how long corporation-ticker resolution may block the singleton + dispatcher, in the same sense as `notable_items_timeout_ms/0`. + + Separate from that budget rather than shared with it because the two do very + different amounts of work: ticker lookups are per-corporation and cached for + an hour, so the steady state is a cache read, while notable items pay an ESI + killmail fetch per kill every time. + """ + def corp_tickers_timeout_ms() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:corp_tickers_timeout_ms, @default_corp_tickers_timeout_ms) + |> validate_positive_integer(:corp_tickers_timeout_ms, @default_corp_tickers_timeout_ms) + end + + @doc """ + Whether Discord messages may carry role/user pings via `allowed_mentions`. + + On by default, unlike `notable_items_enabled?/0`: mentions are already a + per-map, per-webhook opt-in (`MapDiscordWebhook.mention_targets`), so an + instance with nothing configured pings nobody regardless of this flag. + This exists purely as an incident kill-switch โ€” an operator who needs + every mention silenced immediately (a runaway role ping, a compromised + mention target) flips this without touching per-map config or waiting for + a deploy. + + Gates BOTH mention paths: route-alert pings (`EmbedFormatter.route_ping/2`) + and kill-message voice mentions (`DiscordDispatcher.voice_mention_prefix/1`). + Deliberately not folded into `discord_voice_mentions_enabled?/0`, which also + decides whether `VoiceGateway` connects at boot โ€” this must take effect on + the next message, not on the next deploy. + """ + def discord_mentions_enabled?() do + Application.get_env(@app, :external_events, []) + |> Keyword.get(:discord_mentions_enabled, true) + end + + defp validate_positive_integer(value, _key, _default) when is_integer(value) and value > 0, + do: value + + defp validate_positive_integer(value, key, default) do + Logger.warning( + "[Discord] #{key} must be a positive integer, " <> + "got #{inspect(value)}; falling back to #{default}" + ) + + default + end + + # Sibling of `validate_positive_integer/3` for settings where `0` is a + # legitimate value meaning "off" rather than a misconfiguration. Both fall + # back loudly rather than silently. + defp validate_non_negative_integer(value, _key, _default) + when is_integer(value) and value >= 0, + do: value + + defp validate_non_negative_integer(value, key, default) do + Logger.warning( + "[Discord] #{key} must be a non-negative integer, " <> + "got #{inspect(value)}; falling back to #{default}" + ) + + default + end + @decorate cacheable( cache: WandererApp.Cache, key: "map-connection-auto-expire-hours" @@ -119,6 +395,9 @@ defmodule WandererApp.Env do made available to react """ def to_client_env() do - %{detailedKillsDisabled: not wanderer_kills_service_enabled?()} + %{ + detailedKillsDisabled: not wanderer_kills_service_enabled?(), + intelSharingEnabled: intel_sharing_enabled?() + } end end diff --git a/lib/wanderer_app/esi/api_client.ex b/lib/wanderer_app/esi/api_client.ex index 58ab7c4cb..533e1e205 100644 --- a/lib/wanderer_app/esi/api_client.ex +++ b/lib/wanderer_app/esi/api_client.ex @@ -71,6 +71,7 @@ defmodule WandererApp.Esi.ApiClient do } ] |> Keyword.merge(@timeout_opts) + |> Keyword.merge(WandererApp.RouteBuilderClient.connect_opts()) ) def get_routes_eve(hubs, origin, _params, _opts), @@ -292,13 +293,30 @@ defmodule WandererApp.Esi.ApiClient do end end - defp is_access_token_expired?(character_id) do - {:ok, %{expires_at: expires_at} = _character} = - WandererApp.Character.get_character(character_id) - - now = DateTime.utc_now() |> DateTime.to_unix() - - expires_at - now <= 0 + @doc false + # Answers "can we prove this character's access token is still valid?". + # + # Only an integer `expires_at` in the future counts as not-expired. Every other + # shape means we cannot prove validity, so we report expired and let the caller + # take the refresh-and-retry path: + # + # * `expires_at` is nullable on `WandererApp.Api.Character`, so a character + # that has never completed an OAuth exchange carries `nil`. + # * `WandererApp.Character.get_character/1` answers `{:ok, nil}` for a nil id + # and `{:error, :not_found}` for an id that is not in the cache or the DB. + # + # Previously all three raised (ArithmeticError / MatchError) on *every* + # authenticated ESI call โ€” location, online, ship, wallet and search โ€” rather + # than on the corporation search where it was first observed. + def is_access_token_expired?(character_id) do + case WandererApp.Character.get_character(character_id) do + {:ok, %{expires_at: expires_at}} when is_integer(expires_at) -> + now = DateTime.utc_now() |> DateTime.to_unix() + expires_at - now <= 0 + + _other -> + true + end end defp get_corporation_auth_data(corporation_eve_id, info_path, opts), @@ -351,9 +369,11 @@ defmodule WandererApp.Esi.ApiClient do {:ok, body} {:ok, %{status: 504}} -> + emit_esi_error(path, :timeout, pool) {:error, :timeout} {:ok, %{status: 404}} -> + emit_esi_error(path, :not_found, pool) {:error, :not_found} {:ok, %{status: 420, headers: headers} = _error} -> @@ -423,6 +443,7 @@ defmodule WandererApp.Esi.ApiClient do do_get_retry(path, api_opts, opts) {:ok, %{status: status}} -> + emit_esi_error(path, :unexpected_status, pool) {:error, "Unexpected status: #{status}"} {:error, %Mint.TransportError{reason: :timeout}} -> @@ -433,6 +454,8 @@ defmodule WandererApp.Esi.ApiClient do %{method: "GET", path: path, pool: pool} ) + emit_esi_error(path, :pool_timeout, pool) + {:error, :pool_timeout} {:error, reason} -> @@ -446,6 +469,8 @@ defmodule WandererApp.Esi.ApiClient do ) end + emit_esi_error(path, :request_failed, pool) + {:error, "Request failed"} end rescue @@ -469,10 +494,37 @@ defmodule WandererApp.Esi.ApiClient do Logger.error(error_msg) end + emit_esi_error(path, :exception, pool) + {:error, "Request failed"} end end + # [:wanderer_app, :esi, :error] had a PromEx counter declared and no emitter + # anywhere in lib/, so the series never materialized and the panel reading it + # showed "no data" โ€” visually identical to "ESI is healthy". These are the + # branches that were already returning errors silently. + # + # error_type is a fixed set of atoms and endpoint is the path with its numeric + # ids stripped, because both become Prometheus labels: the raw path would open + # one series per character id. + defp emit_esi_error(path, error_type, pool) do + :telemetry.execute( + [:wanderer_app, :esi, :error], + %{count: 1, system_time: System.system_time()}, + %{endpoint: endpoint_label(path), error_type: error_type, tracking_pool: inspect(pool)} + ) + end + + defp endpoint_label(path) when is_binary(path) do + path + |> String.replace(~r{/\d+}, "/:id") + |> String.split("?") + |> List.first() + end + + defp endpoint_label(_path), do: "unknown" + defp maybe_cache_response(path, body, %{"expires" => [expires]} = _headers, opts) when is_binary(path) and not is_nil(expires) do try do @@ -707,7 +759,20 @@ defmodule WandererApp.Esi.ApiClient do pool ) - {:error, _error} -> + {:error, reason} -> + # The reason is deliberately logged rather than returned. It is the + # actionable half of the failure โ€” `:invalid_grant` means "re-authorise + # this character", which is exactly what the corporation-search message + # tells the user โ€” but `tracker.ex` and `transactions_tracker_impl.ex` + # branch on `{:error, :forbidden}` (see the + # `error in [:forbidden, :not_found, :timeout]` guards), so widening + # what this returns would silently change character-tracking behaviour. + # Surfacing it to callers needs those guards revisited first. + Logger.warning( + "TOKEN_REFRESH_FAILED: reporting #{inspect(status)} to caller, refresh reason was #{inspect(reason)}", + character_id: character_id + ) + {:error, status} end end @@ -734,6 +799,31 @@ defmodule WandererApp.Esi.ApiClient do handle_refresh_token_result(refresh_token_result, character, character_id, expires_at, scopes) end + # Seconds since the access token expired, for logs and telemetry only. + # + # `expires_at` is a nullable `:integer` on `WandererApp.Api.Character`, so a + # character that has never completed an OAuth exchange carries `nil` โ€” and + # since `is_access_token_expired?/1` reports exactly those characters as + # expired, they are routed straight into the refresh-and-retry path below. + # `DateTime.from_unix!(nil)` raises `FunctionClauseError`, which took down + # every authenticated ESI call for such a character (the corporation typeahead + # in map settings just swallowed it into an empty dropdown). + # + # Diagnostic timing must never be the thing that fails a request, so an + # unusable `expires_at` degrades to `nil` rather than raising. + # + # Public (like `is_access_token_expired?/1`) only so the regression test can + # reach it; it is not part of the module's API. + @doc false + def time_since_expiry(expires_at) when is_integer(expires_at) do + case DateTime.from_unix(expires_at) do + {:ok, datetime} -> DateTime.diff(DateTime.utc_now(), datetime, :second) + {:error, _reason} -> nil + end + end + + def time_since_expiry(_expires_at), do: nil + defp handle_refresh_token_result( {:ok, %OAuth2.AccessToken{} = token}, character, @@ -742,8 +832,7 @@ defmodule WandererApp.Esi.ApiClient do scopes ) do # Log token refresh success with timing info - expires_at_datetime = DateTime.from_unix!(expires_at) - time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second) + time_since_expiry = time_since_expiry(expires_at) Logger.debug( fn -> @@ -786,8 +875,7 @@ defmodule WandererApp.Esi.ApiClient do expires_at, scopes ) do - expires_at_datetime = DateTime.from_unix!(expires_at) - time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second) + time_since_expiry = time_since_expiry(expires_at) # Track consecutive invalid_grant failures before permanently invalidating tokens. # EVE SSO can return invalid_grant for transient server issues, so we require @@ -835,8 +923,7 @@ defmodule WandererApp.Esi.ApiClient do expires_at, _scopes ) do - expires_at_datetime = DateTime.from_unix!(expires_at) - time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second) + time_since_expiry = time_since_expiry(expires_at) Logger.warning("TOKEN_REFRESH_FAILED: Connection refused during token refresh", character_id: character_id, @@ -862,8 +949,7 @@ defmodule WandererApp.Esi.ApiClient do expires_at, _scopes ) do - time_since_expiry = - DateTime.diff(DateTime.utc_now(), DateTime.from_unix!(expires_at), :second) + time_since_expiry = time_since_expiry(expires_at) Logger.warning("TOKEN_REFRESH_FAILED: Transient OAuth2 error during token refresh", character_id: character_id, @@ -881,8 +967,7 @@ defmodule WandererApp.Esi.ApiClient do end defp handle_refresh_token_result(error, _character, character_id, expires_at, _scopes) do - time_since_expiry = - DateTime.diff(DateTime.utc_now(), DateTime.from_unix!(expires_at), :second) + time_since_expiry = time_since_expiry(expires_at) Logger.warning("TOKEN_REFRESH_FAILED: Unexpected error during token refresh", character_id: character_id, diff --git a/lib/wanderer_app/esi/corporation_search.ex b/lib/wanderer_app/esi/corporation_search.ex new file mode 100644 index 000000000..eeabbc6d2 --- /dev/null +++ b/lib/wanderer_app/esi/corporation_search.ex @@ -0,0 +1,162 @@ +defmodule WandererApp.Esi.CorporationSearch do + @moduledoc """ + Corporation name search against ESI, performed as one of the user's characters. + + ESI's `/search/` endpoint is authenticated, so a search needs a character with + a live access token โ€” which is why this takes a character list rather than a + bare query string. Extracted from `MapSystemsEventHandler` so the map UI and + the notification settings component share one implementation of the + minimum-length rule and the ticker enrichment. + """ + + require Logger + + alias WandererApp.Character + + @min_search_length 3 + + # ESI returns every corporation whose name matches the prefix, and `decorate/1` + # issues one sequential `get_corporation_info/1` per hit โ€” inside the calling + # LiveView process, on every debounced keystroke. A broad term like "corp" + # would otherwise block the settings tab for the sum of hundreds of lookups + # whose results the caller then truncates anyway. Cap first, enrich after. + @max_results 20 + + @doc "Minimum number of characters before a search is sent to ESI." + @spec min_search_length() :: pos_integer() + def min_search_length, do: @min_search_length + + @doc "Maximum number of hits enriched and returned by `search/3`." + @spec max_results() :: pos_integer() + def max_results, do: @max_results + + @doc """ + Searches corporations by name as the first of `characters`. + + Returns `{:ok, []}` when the user has no characters or the term is too short, + so callers can render "no matches" without distinguishing those cases from a + genuinely empty result. An ESI failure is passed through as `{:error, reason}` + and is not swallowed here โ€” the map settings notifications tab turns that into + a visible message, while the system-settings dialog logs it and still renders + an empty dropdown. + + At most `max_results/0` hits are returned. + + Each hit keeps the keys `Character.search/2` produced (`:label`, `:value`, + `:corporation`) and adds `:formatted`, `:name`, `:ticker`, `:id`, `:type`. + `:value` and `:id` are **strings**; callers that persist integers must convert. + + `opts` exists for tests: `:search_fun` replaces `Character.search/2` and + `:fetch_fun` replaces the ticker lookup. + """ + @spec search(list(), any(), keyword()) :: {:ok, list(map())} | {:error, term()} + def search(characters, search, opts \\ []) + + def search([], _search, _opts), do: {:ok, []} + + def search([first_char | _], search, opts) when is_binary(search) do + if String.length(search) < @min_search_length do + {:ok, []} + else + search_fun = Keyword.get(opts, :search_fun, &Character.search/2) + fetch_fun = Keyword.get(opts, :fetch_fun, &WandererApp.Esi.get_corporation_info/1) + + case search_fun.(first_char.id, params: [search: search, categories: "corporation"]) do + {:ok, results} -> + {:ok, results |> Enum.take(@max_results) |> Enum.map(&decorate(&1, fetch_fun))} + + other -> + other + end + end + end + + # A list of characters with a search term that is not a binary is a real, + # expected state: the typeahead fires before anything has been typed. Answering + # `{:ok, []}` is correct there. + def search(characters, _search, _opts) when is_list(characters), do: {:ok, []} + + # Anything else is a programming error, not a user state โ€” most likely + # `characters` arriving as `%Ash.NotLoaded{}` from an unloaded association, or + # the arguments passed in the wrong order. This used to be folded into the + # `{:ok, []}` clause above, which reported success and rendered a permanently + # empty dropdown with nothing in the logs to explain it. + def search(characters, _search, _opts) do + Logger.warning( + "[CorporationSearch] search called with unusable characters: #{inspect(characters)}" + ) + + {:error, :invalid_characters} + end + + @doc """ + The character a search would run as, or `:error` if there is none. + + `search/3` uses the first of `characters` unconditionally, so on a multi-character + account exactly one character's token decides whether the feature works. Callers + need to name that character in a failure message: "re-authorise a character" is + unactionable when the user has several and cannot tell which one is being used โ€” + re-authorising any of the others changes nothing. + + Exposed so the message and the request cannot disagree about which character + that is; both go through here rather than each reaching for the head of the list. + """ + @spec search_character(any()) :: {:ok, map()} | :error + def search_character([first_char | _]), do: {:ok, first_char} + def search_character(_characters), do: :error + + @doc """ + Human-readable label for a stored corporation id. + + Falls back to `to_string(corp_id)` whenever ESI cannot answer: a saved focus + corporation has to stay visible and removable while ESI is down. + """ + @spec label_for(integer() | String.t()) :: String.t() + @spec label_for(integer() | String.t(), (any() -> any())) :: String.t() + def label_for(corp_id, fetch_fun \\ &WandererApp.Esi.get_corporation_info/1) do + case safe_fetch(fetch_fun, corp_id) do + {:ok, %{"name" => name} = info} when is_binary(name) and name != "" -> + format_label(name, Map.get(info, "ticker")) + + _ -> + to_string(corp_id) + end + end + + defp decorate(item, fetch_fun) do + name = Map.get(item, :label, "") + corp_id = Map.get(item, :value, "") + + ticker = + case safe_fetch(fetch_fun, corp_id) do + {:ok, %{"ticker" => ticker}} -> ticker + _ -> "" + end + + Map.merge(item, %{ + formatted: format_label(name, ticker), + name: name, + ticker: ticker, + id: corp_id, + type: "corp" + }) + end + + defp format_label(name, ticker) when is_binary(ticker) and ticker != "", + do: "[#{ticker}] #{name}" + + defp format_label(name, _ticker), do: name + + # ESI is a network dependency reached from a LiveView process; a raise here + # would take the settings tab down over a transient lookup. + defp safe_fetch(fetch_fun, corp_id) do + fetch_fun.(corp_id) + rescue + error -> + Logger.warning( + "[CorporationSearch] lookup failed for #{inspect(corp_id)}: #{inspect(error)}" + ) + + :error + end +end diff --git a/lib/wanderer_app/external_events.ex b/lib/wanderer_app/external_events.ex index 45a57e00c..7bb58257d 100644 --- a/lib/wanderer_app/external_events.ex +++ b/lib/wanderer_app/external_events.ex @@ -13,8 +13,11 @@ defmodule WandererApp.ExternalEvents do # From event producers, call this in ADDITION to existing broadcasts WandererApp.ExternalEvents.broadcast("map_123", :add_system, %{ + system_id: "0198f0a1-2222-7000-8000-000000000002", solar_system_id: 31000199, - name: "J123456" + name: "J123456", + position_x: 100, + position_y: 200 }) This is additive-only and does not replace any existing functionality. @@ -40,16 +43,22 @@ defmodule WandererApp.ExternalEvents do ## Examples - # System events + # System events - see map_server_systems_impl.ex WandererApp.ExternalEvents.broadcast("map_123", :add_system, %{ + system_id: "0198f0a1-2222-7000-8000-000000000002", solar_system_id: 31000199, - name: "J123456" + name: "J123456", + position_x: 100, + position_y: 200 }) - # Kill events + # Kill events carry a BATCH of killmails, not a single kill - + # see kills/message_handler.ex:126 WandererApp.ExternalEvents.broadcast("map_123", :map_kill, %{ - killmail_id: 98765, - victim_ship_type: "Rifter" + "solar_system_id" => 31000199, + "killmails" => [%{"killmail_id" => 98765, "victim_ship_name" => "Rifter"}], + "timestamp" => "2026-08-04T12:00:00Z", + "type" => :killmail_update }) """ @spec broadcast(String.t(), Event.event_type(), map()) :: :ok diff --git a/lib/wanderer_app/external_events/discord/channel_info.ex b/lib/wanderer_app/external_events/discord/channel_info.ex new file mode 100644 index 000000000..bae7271c7 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/channel_info.ex @@ -0,0 +1,647 @@ +defmodule WandererApp.ExternalEvents.Discord.ChannelInfo do + @moduledoc """ + Resolves a human-readable identity for a Discord webhook destination, so the + map settings tab can tell one destination from another without rendering a + credential. + + ## Why + + The settings tab used to render `".../1534657087244603394/40AXโ€ขโ€ขโ€ขโ€ข"`. That is + two problems in one string. The snowflake is the webhook **id** โ€” half of the + `{id, token}` pair that authorises posting โ€” rendered in full on a panel that + gets screenshotted into support threads. And it does not actually identify + anything: two destinations pointed at the same channel render byte-identical + hints, which is how a map ends up with its route alerts quietly sharing the + public kill feed. + + ## Three tiers, degrading + + 1. `GET` the webhook URL itself. This needs no credential beyond the URL, and + returns the webhook's own `name` plus its `channel_id`. Always attempted. + 2. If a bot token is configured (`WandererApp.Env.discord_bot_token/0`), + `GET /channels/{channel_id}` with `Authorization: Bot โ€ฆ` resolves the real + `#channel-name`. The bot must share the guild, so **403 here is normal** + and falls back to tier 1 rather than erroring. + 3. A fully-masked hint derived from a hash of the URL. Unlike the old + `masked_url/1`, the id is masked too โ€” nothing here is ever recoverable + into a credential, and two destinations still render differently because + the hash differs. + + ## What the label claims + + `source` says which tier produced the label, because the UI's wording depends + on it: `:channel` is a real `#channel-name`, `:webhook_name` is only the + nickname whoever created the webhook typed, `:masked` is the hash hint, and + `:unknown` is a label persisted before this field existed. `:unknown` renders + bare โ€” no "Channel:"/"Webhook:" prefix โ€” because guessing the tier from the + label's shape is exactly the inference this field exists to stop. It is also + treated as stale, so a row drains to a real tier on its next render rather + than needing a backfill that nothing recorded enough to write. + + ## Blocking policy + + `describe/1` **never** blocks and never performs I/O: it answers from cache, + then from the label persisted on the row, then from the masked hint, and + schedules a background refresh when the cache is cold. This is deliberate. + The settings template re-renders on every `live_select` keystroke, and three + destinations ร— two HTTP calls on a render path is a settings tab that hangs + for half a minute the first time it is opened. + + `resolve/1` is the blocking counterpart, for callers that want the answer now + (and for tests). + + ## What is never logged + + No function here logs, inspects, or caches under the webhook URL. Cache keys + and log lines carry `fingerprint/1` โ€” a truncated SHA-256 โ€” which identifies + a destination across lines without carrying any part of the credential. + """ + + require Logger + + alias WandererApp.Api.MapDiscordWebhook + alias WandererApp.ExternalEvents.Discord.HttpClient + + @type role :: :system | :character | :route + + @type info :: %{ + label: String.t(), + channel_id: String.t() | nil, + guild_id: String.t() | nil, + source: :channel | :webhook_name | :unknown | :masked + } + + # Role order, and the order collisions are reported in. Mirrors + # `MapDiscordWebhook`'s `:role` constraint. + @roles [:system, :character, :route] + + # `:api_cache` per CLAUDE.md's "Caching Strategy" โ€” the cache for + # third-party lookups, already at a 1h default TTL. Named explicitly rather + # than relying on that default so a change to the cache's configuration + # cannot silently change how long a stale channel name is shown. + @cache :api_cache + @cache_ttl :timer.hours(1) + + # A masked result means "Discord did not answer", which is usually transient. + # Caching it for the full hour would pin every destination on the screen to a + # placeholder for an hour because of one bad minute; not caching it at all + # would re-ask on every render while Discord is down. Short is the middle. + @masked_cache_ttl :timer.minutes(5) + + # Short-lived marker that a background refresh is already in flight, so a + # template re-rendering on every keystroke schedules one task, not one per + # keystroke. Comfortably longer than the client's read timeout, so the lock + # outlives the request it guards. + @refresh_lock_ttl :timer.seconds(30) + + @cache_namespace "discord_channel_info" + @refresh_lock_namespace "discord_channel_info_refresh" + + # Hex characters of the URL fingerprint. 8 for cache keys (collision-free + # enough for a handful of destinations per map) and 4 for the visible hint, + # which only has to differ between the three destinations on one screen. + @fingerprint_length 8 + @hint_length 4 + + @masked_prefix "โ€ขโ€ขโ€ขโ€ข" + + # Kept at or under `MapDiscordWebhook`'s `:channel_label` constraint. Discord + # caps names well below this, so truncation only ever fires on a response + # that is not what it claims to be โ€” and a label that fails the constraint + # would make `persist/2` retry the same rejected write every refresh. + @max_label_length 128 + + # Pinned API version: an unversioned `/api/channels/...` follows Discord's + # current default, which has changed under callers before. + @bot_api_base "https://discord.com/api/v10" + + @doc """ + Non-blocking identity for a destination, suitable for a render path. + + Accepts a `MapDiscordWebhook` record or a raw webhook URL. Answers from + cache, then the label persisted on the record, then a masked hint โ€” + scheduling a background refresh whenever the cache is cold, so the next + render carries the real name. + + Returns `{:error, :no_webhook_url}` when handed nothing usable; callers + rendering an unconfigured destination should not be asking in the first + place, and a `{:ok, "โ€ขโ€ขโ€ขโ€ข"}` there would look like a configured one. + + ## `notify:` + + Pass `notify: self()` from a render path to be told when the background + refresh lands, so the masked hint this call returns is replaced by the real + name instead of sitting there until something else happens to re-render. + The message is + + {:discord_channel_info, notification_id, source} + + a **three**-tuple, deliberately. `MapsLive` carries an unguarded + `handle_info({ref, result}, socket)` catch-all that calls + `Process.demonitor(ref, [:flush])` (`maps_live.ex:651`), and + `Process.demonitor/2` raises `ArgumentError` on anything that is not a + reference โ€” so a two-tuple here would take the whole map LiveView down on the + first refresh. Ordering our clause above the catch-all would also work and is + worse: it makes correctness depend on a source-file position that any later + edit can silently break. + + Nothing is sent for a raw URL (there is no row to name), and a send to a pid + that has since died is a no-op, so a closed settings tab costs nothing. + """ + @spec describe(MapDiscordWebhook.t() | String.t() | nil, keyword()) :: + {:ok, info()} | {:error, term()} + def describe(webhook_or_url, opts \\ []) do + with {:ok, url} <- webhook_url(webhook_or_url) do + case cached(url) do + {:ok, %{source: :masked} = info} -> + # A masked cache entry means the last resolution could not reach + # Discord or could not name the channel. It must not displace a real + # label already on the row โ€” `persist/2` refuses to write one over the + # other for exactly this reason, and letting it win here would undo + # that on screen: a destination showing "#kills" would drop back to a + # hash hint the moment Discord had one bad minute. + {:ok, persisted(webhook_or_url) || info} + + {:ok, info} -> + {:ok, info} + + :miss -> + refresh_async(webhook_or_url, opts) + {:ok, persisted(webhook_or_url) || masked(url)} + end + end + end + + @doc """ + Blocking identity for a destination: performs the tiered resolution above, + caching the result. + + Never raises and never returns a tier-3 `{:error, _}` for a reachable-but- + unidentifiable webhook โ€” a destination that cannot be named still gets its + masked hint, because "we could not reach Discord" and "this destination does + not exist" must not render the same way as each other, and neither should + take down the settings tab. + """ + @spec resolve(MapDiscordWebhook.t() | String.t() | nil) :: {:ok, info()} | {:error, term()} + def resolve(webhook_or_url) do + with {:ok, url} <- webhook_url(webhook_or_url) do + case cached(url) do + {:ok, info} -> {:ok, info} + :miss -> {:ok, put_cached(url, resolve_uncached(url))} + end + end + end + + @doc """ + Resolves in the background and persists the result onto the webhook row, so + the next open of the settings tab renders instantly from the row instead of + waiting on Discord. + + Deduplicated through a short-lived cache lock and always `:ok` โ€” a refresh + that cannot be scheduled is not an error, it is one more render showing the + masked hint. + + Accepts the same `notify:` option as `describe/2`. Note that the lock is what + makes the notification worth having and also its one sharp edge: a caller + whose refresh is deduped against one already in flight is not notified, + because the in-flight task was scheduled by whoever won the lock. That is the + right trade for a settings tab, where losing the race means someone else's + refresh is about to write the row anyway. + """ + @spec refresh_async(MapDiscordWebhook.t() | String.t() | nil, keyword()) :: :ok + def refresh_async(webhook_or_url, opts \\ []) do + with {:ok, url} <- webhook_url(webhook_or_url), + :ok <- acquire_refresh_lock(url) do + Task.Supervisor.start_child(WandererApp.TaskSupervisor, fn -> + info = put_cached(url, resolve_uncached(url)) + persist(webhook_or_url, info) + notify(webhook_or_url, info, opts) + end) + end + + :ok + end + + @doc """ + Given the settings tab's `%{system: _, character: _, route: _}` map, returns + the groups of roles that deliver into the same Discord channel. + + Each group is a list of two or more roles in `#{inspect(@roles)}` order; + `[]` means no collision. A `:route` group matters most: the route alert help + text tells operators the channel names every system in the chain in order and + must be trusted, and sharing it with the public kill feed is a real leak that + nothing else on the screen would reveal. + + Non-blocking, like `describe/1`. Two **different** webhook URLs into one + channel are only detectable once their `channel_id` has resolved and been + persisted; before that they are treated as distinct. The same URL reused + twice is caught immediately, without any resolution at all. + """ + @spec colliding_roles(%{optional(role()) => MapDiscordWebhook.t() | nil}) :: [[role()]] + def colliding_roles(webhooks) when is_map(webhooks) do + @roles + |> Enum.map(&{&1, identity(Map.get(webhooks, &1))}) + |> Enum.reject(fn {_role, identity} -> is_nil(identity) end) + |> Enum.group_by(fn {_role, identity} -> identity end, fn {role, _identity} -> role end) + |> Enum.map(fn {_identity, roles} -> Enum.sort_by(roles, &role_index/1) end) + |> Enum.filter(&(length(&1) > 1)) + |> Enum.sort_by(fn [first | _rest] -> role_index(first) end) + rescue + error -> + # A collision warning that fails to compute must not take the settings + # tab with it; "no collision reported" is the same state the tab was in + # before this module existed. + Logger.warning("[ChannelInfo] collision check failed: #{Exception.message(error)}") + [] + end + + def colliding_roles(_webhooks), do: [] + + @doc """ + Stable, non-reversible short identifier for a destination. + + Public because it is what log lines and support conversations should use to + refer to a specific destination. Never derived from the token: four + characters of a credential are still four characters of a credential. + """ + @spec fingerprint(String.t()) :: String.t() + def fingerprint(url) when is_binary(url) do + :sha256 + |> :crypto.hash(url) + |> Base.encode16(case: :upper) + |> binary_part(0, @fingerprint_length) + end + + ## Resolution tiers + + defp resolve_uncached(url) do + case fetch_webhook(url) do + {:ok, %{"channel_id" => channel_id} = webhook} when is_binary(channel_id) -> + # The webhook payload carries a `guild_id` of its own. It is the weaker + # of the two โ€” tier 2 answers for the channel that is actually being + # posted into โ€” so it is only the fallback. + webhook_guild_id = present(webhook["guild_id"]) + + # Reachable and it named a channel, but a name is a separate question: + # the bot may not share the guild and the webhook itself may be + # unnamed. Falling back to the masked hint here must NOT count as + # resolved, or the hint would be cached for the full hour and written + # over whatever real label the row already holds. + case bot_channel(channel_id) do + %{label: label, guild_id: guild_id} -> + %{ + label: label, + channel_id: channel_id, + guild_id: guild_id || webhook_guild_id, + source: :channel + } + + nil -> + case webhook_label(webhook) do + nil -> + %{ + label: masked_label(url), + channel_id: channel_id, + guild_id: webhook_guild_id, + source: :masked + } + + label -> + %{ + label: label, + channel_id: channel_id, + guild_id: webhook_guild_id, + source: :webhook_name + } + end + end + + {:ok, webhook} -> + # Reachable, but Discord did not hand back a channel โ€” nothing to ask + # the bot about, and nothing to collide on. + case webhook_label(webhook) do + nil -> + masked(url) + + label -> + %{ + label: label, + channel_id: nil, + guild_id: present(webhook["guild_id"]), + source: :webhook_name + } + end + + :error -> + masked(url) + end + end + + # Tier 1. Authorised by the URL itself; deliberately sends no bot token. + defp fetch_webhook(url) do + case safe_get(url, [], "webhook identity", url) do + {:ok, 200, body} -> decode(body, url) + _other -> :error + end + end + + # Tier 2. A 401/403 here is the ordinary case for an instance whose bot is + # not in the operator's guild, so it is not logged as a failure. + # + # Returns the guild alongside the name: this is the only response that ties a + # destination to a specific guild authoritatively, and the mention pickers + # need it to know which guild's roles and members to offer. + defp bot_channel(channel_id) do + with token when is_binary(token) <- WandererApp.Env.discord_bot_token(), + {:ok, 200, body} <- + safe_get( + "#{@bot_api_base}/channels/#{channel_id}", + [{"authorization", "Bot #{token}"}], + "channel lookup", + channel_id + ), + {:ok, %{"name" => name} = channel} <- decode(body, channel_id), + name when is_binary(name) <- present(name) do + %{label: truncate("##{name}"), guild_id: present(channel["guild_id"])} + else + _ -> nil + end + end + + defp webhook_label(%{"name" => name}), do: name |> present() |> truncate() + defp webhook_label(_webhook), do: nil + + defp truncate(nil), do: nil + defp truncate(label), do: String.slice(label, 0, @max_label_length) + + ## Masking + + defp masked(url), + do: %{label: masked_label(url), channel_id: nil, guild_id: nil, source: :masked} + + defp masked_label(url) do + "#{@masked_prefix} #{url |> fingerprint() |> binary_part(0, @hint_length)}" + end + + ## Cache + + defp cached(url) do + case Cachex.get(@cache, cache_key(url)) do + {:ok, %{label: _label} = info} -> {:ok, info} + _ -> :miss + end + rescue + _error -> :miss + end + + defp put_cached(url, info) do + Cachex.put(@cache, cache_key(url), info, ttl: ttl_for(info)) + info + rescue + _error -> info + end + + defp ttl_for(%{source: :masked}), do: @masked_cache_ttl + # A guard, not the draining mechanism. `put_cached/2` is only ever handed + # `resolve_uncached/1`'s output, which never carries `:unknown` โ€” a legacy row + # reaches the UI through `persisted/1` on a cache miss, and that same miss is + # what schedules the refresh that records the tier. This clause exists so that + # if an `:unknown` ever does get cached, it gets the short TTL rather than + # falling through to the hour-long one below. + defp ttl_for(%{source: :unknown}), do: @masked_cache_ttl + defp ttl_for(_info), do: @cache_ttl + + # `:ok` only for the caller that won the lock. `get_and_update/3` is the + # atomic half: two concurrent renders cannot both commit. + # + # The expiry is carried in the *value*, not in a Cachex TTL, because setting a + # TTL is a second call โ€” and a task that dies in the window between claiming + # the lock and expiring it would leave a lock with no expiry at all, i.e. a + # destination stuck on its masked hint until the node restarts. + defp acquire_refresh_lock(url) do + now = System.monotonic_time(:millisecond) + + Cachex.get_and_update(@cache, refresh_lock_key(url), fn + held_until when is_integer(held_until) and held_until > now -> {:ignore, held_until} + _expired_or_absent -> {:commit, now + @refresh_lock_ttl} + end) + |> case do + {:commit, _held_until} -> :ok + _ -> :locked + end + rescue + # No cache means no dedupe, and refreshing anyway is better than a tab + # permanently stuck on masked hints. + _error -> :ok + end + + defp cache_key(url), do: "#{@cache_namespace}:#{fingerprint(url)}" + defp refresh_lock_key(url), do: "#{@refresh_lock_namespace}:#{fingerprint(url)}" + + ## Persistence + + # Only a saved row has somewhere to persist to; a raw URL string (a paste + # not yet submitted) resolves into the cache and stops there. + defp persist(%MapDiscordWebhook{id: id} = webhook, %{source: source} = info) + when is_binary(id) and source in [:channel, :webhook_name] do + if stale?(webhook, info) do + case MapDiscordWebhook.cache_channel_info(webhook, %{ + channel_id: info.channel_id, + channel_label: info.label, + channel_label_source: source, + guild_id: info.guild_id + }) do + {:ok, _record} -> + :ok + + {:error, error} -> + # A rejected write is silent otherwise: the row keeps its stale label + # and every refresh retries the same rejected change forever. + Logger.warning( + "[ChannelInfo] could not persist identity for webhook #{id}: #{error_summary(error)}" + ) + end + end + + :ok + rescue + error -> + Logger.warning( + "[ChannelInfo] could not persist identity for webhook #{id}: #{Exception.message(error)}" + ) + + :ok + end + + # A masked result is never persisted: it carries no information the row does + # not already imply, and writing it would overwrite a good label that a + # single unreachable moment produced no better answer than. + defp persist(_webhook_or_url, _info), do: :ok + + # All four fields are compared, so a row that already has the right label but + # a null `channel_label_source` โ€” every row written before that column + # existed โ€” still counts as stale and gets its tier recorded on the first + # refresh that reaches it. + defp stale?(%MapDiscordWebhook{} = webhook, info) do + webhook.channel_id != info.channel_id or webhook.channel_label != info.label or + webhook.channel_label_source != info.source or webhook.guild_id != info.guild_id + end + + ## Notification + + # Sent whatever the outcome, including `:masked`. A refresh that could do no + # better than the hint is still news to a tab that has been showing the hint + # with no way to know whether it is still waiting. + # + # Three-tuple: see `describe/2`. Two-tuple crashes `MapsLive`. + defp notify(%MapDiscordWebhook{notification_id: notification_id}, info, opts) + when is_binary(notification_id) do + case Keyword.get(opts, :notify) do + pid when is_pid(pid) -> send(pid, {:discord_channel_info, notification_id, info.source}) + _no_listener -> :ok + end + + :ok + end + + defp notify(_webhook_or_url, _info, _opts), do: :ok + + # Never `inspect/1` an Ash error: `InvalidAttribute` carries the submitted + # value, and `sensitive? true` does not redact it. Only this action's four + # fields could appear here and none is a credential, but the rule is worth + # keeping mechanical rather than reasoning about it at each call site โ€” so + # this reports field names and messages and never a value. + defp error_summary(%{errors: errors}) when is_list(errors) and errors != [] do + Enum.map_join(errors, "; ", fn + %{field: field, message: message} when not is_nil(field) -> "#{field}: #{message}" + %{message: message} -> to_string(message) + other -> error_summary(other) + end) + end + + defp error_summary(%struct_name{}), do: inspect(struct_name) + defp error_summary(error) when is_atom(error), do: to_string(error) + defp error_summary(_error), do: "unknown error" + + defp persisted(%MapDiscordWebhook{channel_label: label} = webhook) do + case present(label) do + nil -> + nil + + label -> + %{ + label: label, + channel_id: present(webhook.channel_id), + guild_id: present(webhook.guild_id), + # Null means the row predates the column. The label is still worth + # showing โ€” it is what the operator sees today โ€” but nothing recorded + # which tier produced it, so the UI must not claim one. + source: webhook.channel_label_source || :unknown + } + end + end + + defp persisted(_webhook_or_url), do: nil + + ## Collision identity + + # Prefers the resolved channel โ€” that is what "same destination" actually + # means โ€” and falls back to the URL fingerprint so an identical URL pasted + # into two roles is caught before anything resolves. + defp identity(%MapDiscordWebhook{} = webhook) do + case present(webhook.channel_id) do + nil -> + case webhook_url(webhook) do + {:ok, url} -> resolved_channel_id(url) || {:url, fingerprint(url)} + _ -> nil + end + + channel_id -> + channel_id + end + end + + defp identity(_webhook), do: nil + + defp resolved_channel_id(url) do + case cached(url) do + {:ok, %{channel_id: channel_id}} -> present(channel_id) + :miss -> nil + end + end + + defp role_index(role), do: Enum.find_index(@roles, &(&1 == role)) || length(@roles) + + ## Input normalization + + defp webhook_url(%MapDiscordWebhook{webhook_url: url}) when is_binary(url) do + case present(url) do + nil -> {:error, :no_webhook_url} + url -> {:ok, url} + end + end + + defp webhook_url(url) when is_binary(url) do + case present(url) do + nil -> {:error, :no_webhook_url} + url -> {:ok, url} + end + end + + defp webhook_url(_webhook_or_url), do: {:error, :no_webhook_url} + + ## HTTP + + # `search_corporations/2` in the settings component carries a `rescue` for + # exactly this reason: an unrescued raise in a token-refresh path killed the + # whole settings tab on a single keystroke. Every HTTP call here is on a + # render or background path and gets the same treatment, plus `catch` for the + # `:exit` a dead Finch pool produces, which `rescue` alone does not cover. + # + # `context` and `subject` are for the log line only. `subject` is never the + # URL for a webhook read โ€” it is passed through `fingerprint/1` โ€” so no log + # line here can carry a credential. + defp safe_get(url, headers, context, subject) do + HttpClient.get(url, headers) + rescue + error -> + Logger.warning( + "[ChannelInfo] #{context} raised for #{ref(subject)}: #{Exception.message(error)}" + ) + + :error + catch + :exit, _reason -> + Logger.warning("[ChannelInfo] #{context} exited for #{ref(subject)}") + :error + end + + # A webhook URL is fingerprinted; a channel id is already public and is + # exactly what makes a log line useful. + defp ref(subject) do + if String.starts_with?(subject, "http"), do: fingerprint(subject), else: subject + end + + defp decode(body, subject) do + case Jason.decode(body) do + {:ok, %{} = decoded} -> + {:ok, decoded} + + _ -> + # Never `inspect/1` the body: an error response from a proxy sitting in + # front of Discord can echo the request line, and the request line is + # the webhook URL. + Logger.debug(fn -> "[ChannelInfo] unparseable response for #{ref(subject)}" end) + :error + end + end + + defp present(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp present(_value), do: nil +end diff --git a/lib/wanderer_app/external_events/discord/corp_tickers.ex b/lib/wanderer_app/external_events/discord/corp_tickers.ex new file mode 100644 index 000000000..fe3059340 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/corp_tickers.ex @@ -0,0 +1,191 @@ +defmodule WandererApp.ExternalEvents.Discord.CorpTickers do + @moduledoc """ + Resolves the corporation tickers a kill embed renders, for kills whose + websocket payload arrived without them. + + ## Why this is needed + + `EmbedFormatter` renders `(TICKER)` after the victim and the final blow, and + drops the whole parenthetical when the ticker is nil + (`embed_formatter.ex:364`). `MessageHandler.get_corp_ticker/1` reads the + ticker straight out of the wanderer-kills payload and never falls back, so a + payload that omits it produces a notification with no corporation at all โ€” + the reported symptom. Every wanderer-kills serializer rejects nil fields + before encoding, which makes "upstream had not enriched this kill yet" + indistinguishable from "this corporation has no ticker". + + The corporation **ids** do not have that problem: they come off the raw ESI + killmail, so `victim_corp_id` / `final_blow_corp_id` are present even when the + tickers are not. This module turns those ids into tickers. + + wanderer-notifier never hit this because it never trusted the payload โ€” it + resolved the ticker from `corporation_id` through cacheโ†’ESI on every kill + (`killmail_formatter.ex:389-418`). + + ## Scope + + Discord path only, and only for the two fields the embed actually renders. + `top_damage_corp_ticker` is carried in the payload but never rendered, so it + is not resolved. Enriching in `MessageHandler` instead would also fix the + kills widget, but would put an ESI lookup on every kill in every subscribed + system rather than on the handful that become notifications. + + Lookups go through `WandererApp.Esi.get_corporation_info/1`, which is + Nebulex-cached with a 1h TTL (`esi/api_client.ex:151`), so a busy chain + resolves each corporation once an hour, not once a kill. + + ## Fail-open + + A corporation that will not resolve is simply left absent, and the embed + renders as it does today โ€” pilot name with no parenthetical. A missing ticker + is an acceptable outcome; a missing kill notification is not. Rendering + wanderer-notifier's literal `"????"` placeholder was considered and rejected: + omitting reads as "no corp shown", `"????"` reads as "corp is called ????". + """ + + require Logger + + # The pairs the embed renders. Order is irrelevant; membership is not โ€” adding + # a field here without a matching `corporation_link/2` call in the formatter + # buys ESI calls for something nobody sees. + @fields [ + {"victim_corp_id", "victim_corp_ticker"}, + {"final_blow_corp_id", "final_blow_corp_ticker"} + ] + + # Matches `NotableItems`: bounded so one batch cannot flood the ESI pool on + # the dispatcher's behalf. Cache hits do not consume a slot for long. + @esi_concurrency 8 + + @typedoc "Corporation id, normalized to a string โ€” payload ids are not consistently typed." + @type corp_id :: String.t() + + @doc """ + Returns `%{corp_id => ticker}` for the corporations these kills need and did + not carry. + + Corporations that fail to resolve are **absent from the map**. Keys are + stringified ids; use `apply_tickers/2` rather than reading the map directly. + """ + @callback enrich([map()]) :: %{optional(corp_id()) => String.t()} + + @doc "Returns the configured enricher, so the dispatcher can inject a stub." + def impl, do: Application.get_env(:wanderer_app, :corp_tickers_enricher, __MODULE__) + + @spec enrich([map()]) :: %{optional(corp_id()) => String.t()} + def enrich([]), do: %{} + + def enrich(kills) do + case missing_corp_ids(kills) do + [] -> + %{} + + corp_ids -> + corp_ids + |> Task.async_stream(&resolve/1, + max_concurrency: @esi_concurrency, + timeout: element_timeout_ms(), + on_timeout: :kill_task, + ordered: false + ) + |> Enum.reduce(%{}, fn + {:ok, {corp_id, ticker}}, acc -> Map.put(acc, corp_id, ticker) + _result, acc -> acc + end) + end + end + + @doc """ + Fills in whichever of the rendered ticker fields this kill is missing. + + Never overwrites a ticker the payload already carried: upstream saw the + killmail, we only saw the corporation. + """ + @spec apply_tickers(map(), %{optional(corp_id()) => String.t()}) :: map() + def apply_tickers(kill, tickers) when map_size(tickers) == 0, do: kill + + def apply_tickers(kill, tickers) do + Enum.reduce(@fields, kill, fn {id_key, ticker_key}, acc -> + with nil <- present(acc[ticker_key]), + id when not is_nil(id) <- normalize(acc[id_key]), + ticker when is_binary(ticker) <- Map.get(tickers, id) do + Map.put(acc, ticker_key, ticker) + else + _ -> acc + end + end) + end + + @doc "The corporation ids these kills need looked up. Public for the dispatcher's telemetry." + @spec missing_corp_ids([map()]) :: [corp_id()] + def missing_corp_ids(kills) do + kills + |> Enum.flat_map(fn kill -> + Enum.flat_map(@fields, fn {id_key, ticker_key} -> + case {present(kill[ticker_key]), normalize(kill[id_key])} do + {nil, id} when not is_nil(id) -> [id] + _ -> [] + end + end) + end) + |> Enum.uniq() + end + + defp resolve(corp_id) do + case safe(corp_id, fn -> esi_client().get_corporation_info(corp_id) end) do + {:ok, %{"ticker" => ticker}} when is_binary(ticker) -> + case present(ticker) do + nil -> :unresolved + ticker -> {corp_id, ticker} + end + + _ -> + :unresolved + end + end + + # Strictly smaller than the budget the dispatcher gives the whole enrichment, + # and that gap is the point. Both deadlines start at roughly the same moment, + # so a per-element timeout equal to the whole budget means the dispatcher + # shuts this task down before `on_timeout: :kill_task` can drop the slow + # corporation โ€” the corporations that already resolved die with it. Half the + # budget leaves room for the stream to finish and hand back what it got. + defp element_timeout_ms do + WandererApp.Env.corp_tickers_timeout_ms() + |> div(2) + |> max(1) + end + + defp normalize(id) when is_integer(id), do: Integer.to_string(id) + defp normalize(id) when is_binary(id), do: present(id) + defp normalize(_id), do: nil + + defp present(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp present(_value), do: nil + + defp esi_client, do: Application.get_env(:wanderer_app, :esi_client, WandererApp.Esi) + + # An exception in an `async_stream` child brings down the whole stream, which + # would discard the corporations that had already resolved. + # + # The corporation id is carried into the message because the concurrency means + # a systemic failure produces several identical lines at once, with nothing + # else to tell them apart by. + defp safe(corp_id, fun) do + fun.() + rescue + error -> + Logger.warning("[CorpTickers] corporation #{corp_id}: #{Exception.message(error)}") + :error + catch + :exit, reason -> + Logger.warning("[CorpTickers] corporation #{corp_id} exited: #{inspect(reason)}") + :error + end +end diff --git a/lib/wanderer_app/external_events/discord/embed_formatter.ex b/lib/wanderer_app/external_events/discord/embed_formatter.ex new file mode 100644 index 000000000..23e5ef67c --- /dev/null +++ b/lib/wanderer_app/external_events/discord/embed_formatter.ex @@ -0,0 +1,718 @@ +defmodule WandererApp.ExternalEvents.Discord.EmbedFormatter do + @moduledoc """ + Turns flattened killmails into Discord message bodies. + + Only `killmail_id`, `kill_time` and `solar_system_id` are guaranteed present + on a killmail (see `WandererApp.Kills.MessageHandler`), so every other field + is rendered defensively. + + Each kill arrives paired with the involvement verdict from + `WandererApp.ExternalEvents.Discord.Matcher.involvement/3`. The verdict, not + the payload, decides the colour and the author line. + """ + + require Logger + + alias WandererApp.ExternalEvents.Discord.Mentions + alias WandererApp.ExternalEvents.Discord.SystemName + + @type verdict :: + {:involved, :victim} | {:involved, :attacker} | :not_involved | :unknown + + @max_embeds_per_message 10 + @max_kills_per_event 30 + + # Discord's documented embed limits. Exceeding any of them is a 400, not a + # truncation, and a 400 counts as a delivery failure โ€” so ten kills in a + # system whose map-local name is long enough would trip + # `@max_consecutive_failures` and auto-disable the destination. `custom_name` + # and `temporary_name` carry no length constraint on `MapSystem`, so the + # title bound is reachable from ordinary user input, not just malice. + @max_title_length 256 + @max_description_length 4096 + # Discord's per-field value bound. Still the tightest bound in the route + # embed's exit field, whose system names carry no length constraint on + # `MapSystem`. The route PATH now renders as the description rather than a + # field, so it is bounded by @max_description_length instead โ€” that is the + # looser of the two, and the path is the one string built from an unbounded + # number of unbounded names (up to route_max_jumps + 1 systems, each of which + # may carry a length-unconstrained custom_name). + @max_field_length 1024 + # The per-message ceiling counts the text of every embed in the message + # together, so it can be breached by a batch that satisfies each field bound + # individually. + @max_message_text 6000 + + @color_loss 0xE74C3C + @color_kill 0x2ECC71 + + # Route alerts are logistics, not combat, and they share a channel with kill + # embeds โ€” so they deliberately sit outside the kill palette's hues. Red, + # green, yellow and orange are all spoken for by @color_loss, @color_kill and + # the @value_colors tiers below; blue is unclaimed, and it is also The Forge's + # own colour, which is where the alert always points. + # + # The two states are one hue at two lightness steps rather than two hues: the + # family should read as "route" at a glance, with `:improved` legibly the + # quieter of the two. An `:improved` alert also carries no ping (see + # `route_ping/2`), so the dimmer stripe matches how loud the message is. + @color_route_opened 0x2E9BD6 + @color_route_improved 0x2A6E90 + + # What `Evaluator`'s pinned @solver_settings actually guarantee, in the + # reader's language rather than the config's. This is the whole value of the + # alert โ€” that the route needs no scouting โ€” and it was previously invisible, + # so a reader had to know the internals to trust the message. + # + # Deliberately makes NO ship-class claim: `include_cruise: true` means a + # cruiser-sized hole qualifies, so "freighter-safe" would be false. The three + # exclusions are stated plainly and the reader judges their own hull. + # `route_guarantee_settings/0` and its test pin this string to the settings it + # describes, because drift here turns a safety guarantee into a lie. + @route_guarantee "highsec only ยท no EOL ยท no crit ยท no frigate holes" + + # ISK tiers for kills involving nobody we track, largest first. + # + # NOTE: @color_kill (0x2ECC71) and the 10M tier (0x00FF00) are both green. + # They are *distinct meanings* that happen to share a hue โ€” "you killed + # something" versus "a bystander kill worth 10M-100M" โ€” and they are + # disambiguated by the author line, which is present on a kill and absent on + # an uninvolved embed. Do not collapse these two constants into one. + @value_colors [ + {5_000_000_000, 0xFF0000}, + {1_000_000_000, 0xFF6600}, + {100_000_000, 0xFFFF00}, + {10_000_000, 0x00FF00} + ] + @color_default 0x808080 + + @zkill_base "https://zkillboard.com" + @image_base "https://images.evetech.net" + @thumbnail_size 1024 + + # ISK magnitude table, largest first: {threshold, divisor, unit, next_unit}. + # `next_unit` is what a value promotes to when rounding pushes it to >= 1000 + # within its own unit. It is nil at the top so T clamps instead of promoting, + # which makes self-promotion impossible by construction. + @isk_units [ + {1_000_000_000_000, 1_000_000_000_000, "T", nil}, + {1_000_000_000, 1_000_000_000, "B", "T"}, + {1_000_000, 1_000_000, "M", "B"}, + {1_000, 1_000, "K", "M"} + ] + + @doc """ + The per-event kill cap. Exposed so callers can tell which kills were actually + formatted โ€” the dispatcher must not mark kills past this cap as attempted, + since they are never rendered into a message. + """ + @spec max_kills_per_event() :: pos_integer() + def max_kills_per_event, do: @max_kills_per_event + + @spec format_batch([{map(), verdict()}], String.t() | nil) :: [map()] + def format_batch([], _system_name), do: [] + + def format_batch(entries, system_name) do + total = length(entries) + shown = Enum.take(entries, @max_kills_per_event) + overflow = total - length(shown) + + messages = + shown + |> Enum.map(fn {kill, verdict} -> format_kill(kill, verdict, system_name) end) + |> chunk_messages() + |> Enum.map(&%{"embeds" => &1}) + + append_overflow(messages, overflow) + end + + @doc """ + Formats a route-alert transition (design ยง"Message, mentions, and privacy") + into Discord message chunks. `opts[:mention_targets]` are guild-scoped + snowflake strings (`"user:123"` / `"role:456"`); pinging is gated on + `WandererApp.Env.discord_mentions_enabled?/0` and fires only for `:opened` + (design: "Ping on open only" โ€” an "improved" update posts with no `content`, + keeping the ping meaningful on a chain under active scanning). + """ + @spec format_route_alert(map(), keyword()) :: [map()] + def format_route_alert(alert, opts) do + embed = route_embed(alert) + mention_targets = Keyword.get(opts, :mention_targets, []) + + message = + case route_ping(alert.kind, mention_targets) do + nil -> + %{"embeds" => [embed]} + + {content, allowed_mentions} -> + %{"embeds" => [embed], "content" => content, "allowed_mentions" => allowed_mentions} + end + + [message] + end + + defp route_embed(alert) do + %{ + "author" => %{"name" => route_kind_label(alert.kind)}, + "title" => truncate(route_title(alert), @max_title_length), + "url" => map_url(alert.map_id), + "color" => route_color(alert.kind), + "description" => truncate(route_path_text(alert), @max_description_length), + "footer" => %{"text" => @route_guarantee}, + # Renders client-side as the reader's local time. A qualifying route is + # perishable โ€” the chain it runs through can roll or die within the hour โ€” + # so "how old is this alert" is part of the decision, not metadata. + "timestamp" => DateTime.utc_now() |> DateTime.to_iso8601() + } + |> put_route_fields(alert) + |> drop_nils() + end + + # The exit field earns its place only when the exit is NOT the destination. + # `find_exit_system/2` returns the first non-wormhole system in path order, so + # on a chain that pops straight into Jita it returns Jita โ€” and "Exit system: + # Jita" then restates the last token of the path line directly above it. When + # the exit is somewhere else it is the most decision-relevant fact in the + # message (where the chain touches k-space), so it gets stated with the gate + # distance that makes it actionable. + defp put_route_fields(embed, alert) do + case route_exit_field(alert) do + nil -> embed + field -> Map.put(embed, "fields", [field]) + end + end + + defp route_exit_field(%{exit_system: nil}), do: nil + + defp route_exit_field(alert) do + destination = List.last(alert.path) + + if alert.exit_system == destination do + nil + else + %{ + "name" => "Exit", + "value" => + truncate( + "#{route_system_name(alert, alert.exit_system)} ยท #{gates_from_exit(alert)} from #{route_system_name(alert, destination)}", + @max_field_length + ), + "inline" => true + } + end + end + + # Gates remaining between the exit and the destination. Read off the solved + # path by position rather than recomputed: the path IS the route, so the hops + # after the exit's index are exactly the k-space legs left to fly. Falls back + # to the full jump count if the exit is somehow not on the path, which + # `find_exit_system/2` cannot produce but which keeps this function total โ€” + # a raise here costs the whole alert, not just the field. + defp gates_from_exit(alert) do + remaining = + case Enum.find_index(alert.path, &(&1 == alert.exit_system)) do + nil -> alert.jumps + index -> length(alert.path) - 1 - index + end + + pluralize(remaining, "gate") + end + + defp route_kind_label(:opened), do: "Route opened" + defp route_kind_label(:improved), do: "Route shortened" + + defp route_color(:opened), do: @color_route_opened + defp route_color(:improved), do: @color_route_improved + + # Origin and destination live in the TITLE, not only in the description, + # because a mobile push preview shows the title and nothing else. The origin + # resolves map-local first (see `route_system_name/2`), so a map that names its + # home "Home" reads as "Home โ†’ Jita" with no special-casing here โ€” that naming + # decision belongs to the map, not to this formatter. + # + # The delta clause comes first and everything else falls through to the plain + # total: that covers `:opened` (which has no previous count by construction) + # and an `:improved` alert whose previous count is somehow absent, without + # writing the same title string twice. Two copies of one format string is how + # the separator drifts in one place and not the other. + # + # The delta itself is why `previous_jumps` is threaded down from the watcher's + # transition table at all: 3 โ†’ 2 is a shrug and 7 โ†’ 2 is news, and the bare + # "2 jumps" of the old format could not tell them apart. + defp route_title(%{kind: :improved, previous_jumps: previous} = alert) + when is_integer(previous) do + "#{route_origin_name(alert)} โ†’ #{route_destination_name(alert)} ยท #{previous} โ†’ #{pluralize(alert.jumps, "jump")}" + end + + defp route_title(alert) do + "#{route_origin_name(alert)} โ†’ #{route_destination_name(alert)} ยท #{pluralize(alert.jumps, "jump")}" + end + + defp route_origin_name(alert), do: route_system_name(alert, List.first(alert.path)) + defp route_destination_name(alert), do: route_system_name(alert, List.last(alert.path)) + + defp pluralize(1, unit), do: "1 #{unit}" + defp pluralize(count, unit), do: "#{count} #{unit}s" + + # The destination is bolded because it is the one hop the reader is scanning + # for; every other hop is context for getting there. + defp route_path_text(alert) do + destination = List.last(alert.path) + + Enum.map_join(alert.path, " โ†’ ", fn system_id -> + name = route_system_name(alert, system_id) + if system_id == destination, do: "**#{name}**", else: name + end) + end + + # Makes the embed title a link back to the map that raised the alert, so the + # message is not a dead end. + # + # Returns nil rather than a best-effort URL on every failure path. A malformed + # `url` is a 400 from Discord, not a broken link in the client โ€” and a 400 is + # a delivery failure, which counts toward `@max_consecutive_failures` and can + # auto-disable the destination. `Env.base_url/0` defaults to the literal + # placeholder "" when unconfigured, so the scheme check is load- + # bearing, not defensive padding. + defp map_url(map_id) when is_binary(map_id) do + with %URI{scheme: scheme, host: host} <- URI.parse(WandererApp.Env.base_url()), + true <- scheme in ["http", "https"], + true <- is_binary(host) and host != "", + {:ok, %{slug: slug}} when is_binary(slug) <- WandererApp.Api.Map.by_id(map_id) do + "#{String.trim_trailing(WandererApp.Env.base_url(), "/")}/#{slug}" + else + _ -> nil + end + rescue + error -> + Logger.debug(fn -> + "[EmbedFormatter] map url lookup failed for #{map_id}: #{inspect(error)}" + end) + + nil + end + + defp map_url(_map_id), do: nil + + @doc """ + The `Evaluator` settings that `@route_guarantee` claims to describe, as + `{key, required_value}` pairs. Exposed so a test can assert the string and the + solver cannot drift apart โ€” the footer is a safety guarantee, and a stale one + is worse than none. + """ + @spec route_guarantee_settings() :: keyword() + def route_guarantee_settings, + do: [include_eol: false, include_mass_crit: false, include_frig: false] + + @doc false + @spec route_guarantee() :: String.t() + def route_guarantee, do: @route_guarantee + + # Literal :route, per SystemName's map-local-names privacy boundary โ€” never + # threaded through as a variable. See the Router moduledoc's "Role + # resolution is literal" note and SystemName's own moduledoc. + defp route_system_name(_alert, nil), do: "Unknown system" + + defp route_system_name(alert, solar_system_id) do + SystemName.display_name(alert.map_id, solar_system_id, :route) || "Unknown system" + end + + defp route_ping(:improved, _mention_targets), do: nil + defp route_ping(:opened, []), do: nil + + defp route_ping(:opened, mention_targets) do + if WandererApp.Env.discord_mentions_enabled?() do + case Mentions.prefix(mention_targets) do + nil -> nil + content -> {content, Mentions.allowed_mentions(mention_targets)} + end + end + end + + # Two bounds at once: at most @max_embeds_per_message embeds, and at most + # @max_message_text characters of embed text across them. Each embed is + # already within the per-field limits by construction, so a single embed can + # never exceed the message total on its own and this always terminates. + defp chunk_messages(embeds) do + embeds + |> Enum.reduce([], fn embed, acc -> + size = embed_text_length(embed) + + case acc do + [{chunk, chunk_size} | rest] + when length(chunk) < @max_embeds_per_message and chunk_size + size <= @max_message_text -> + [{[embed | chunk], chunk_size + size} | rest] + + _ -> + [{[embed], size} | acc] + end + end) + |> Enum.reverse() + |> Enum.map(fn {chunk, _size} -> Enum.reverse(chunk) end) + end + + # Discord counts title, description, field names and values, footer text and + # author name toward the per-message total. URLs and colours do not count. + defp embed_text_length(embed) do + fields = + embed + |> Map.get("fields", []) + |> Enum.map(&(String.length(&1["name"] || "") + String.length(&1["value"] || ""))) + |> Enum.sum() + + String.length(embed["title"] || "") + + String.length(embed["description"] || "") + + String.length(get_in(embed, ["footer", "text"]) || "") + + String.length(get_in(embed, ["author", "name"]) || "") + + fields + end + + defp append_overflow(messages, overflow) when overflow <= 0, do: messages + + defp append_overflow(messages, overflow) do + {init, [last]} = Enum.split(messages, -1) + init ++ [Map.put(last, "content", "โ€ฆand #{overflow} more kills not shown.")] + end + + @spec format_kill(map(), verdict(), String.t() | nil) :: map() + def format_kill(kill, verdict, system_name) do + %{ + "title" => truncate(title(kill, system_name), @max_title_length), + "url" => zkill_url(kill["killmail_id"]), + "color" => color(verdict, kill["total_value"]), + "description" => full_description(kill), + "fields" => fields(kill) + } + |> maybe_put("author", author(kill, verdict)) + |> maybe_put("thumbnail", thumbnail(kill)) + |> maybe_put("footer", footer(kill)) + |> drop_nils() + end + + # Ellipsis rather than a hard cut, so a clipped name reads as clipped instead + # of as a differently-named system. Measured in graphemes, matching how + # Discord counts: a name of emoji or non-Latin script would otherwise pass a + # byte-based check and still be rejected. + defp truncate(nil, _limit), do: nil + + defp truncate(text, limit) when is_binary(text) do + if String.length(text) <= limit, + do: text, + else: String.slice(text, 0, limit - 1) <> "โ€ฆ" + end + + defp title(kill, system_name) do + ship = present(kill["victim_ship_name"]) || "Unknown ship" + system = present(system_name) || "Unknown system" + "#{ship} destroyed in #{system}" + end + + defp color({:involved, :victim}, _value), do: @color_loss + defp color({:involved, :attacker}, _value), do: @color_kill + + # `:unknown` renders exactly like `:not_involved`. Both mean "we cannot label + # this a kill or a loss", and an `:unknown` embed is already in the system + # channel โ€” colouring it as ours would be a claim the verdict does not support. + defp color(verdict, value) when verdict in [:not_involved, :unknown] and is_number(value) do + Enum.find_value(@value_colors, @color_default, fn {threshold, color} -> + if value >= threshold, do: color + end) + end + + defp color(verdict, _value) when verdict in [:not_involved, :unknown], do: @color_default + + # Omitted entirely when we are not involved, or could not tell: neither "Kill" + # nor "Loss" would be a true statement about the fight. + defp author(_kill, verdict) when verdict in [:not_involved, :unknown], do: nil + defp author(kill, {:involved, :victim}), do: author_line("Loss", kill["victim_corp_id"]) + defp author(kill, {:involved, :attacker}), do: author_line("Kill", kill["final_blow_corp_id"]) + + defp author_line(label, corp_id) when is_integer(corp_id) or is_binary(corp_id) do + %{ + "name" => label, + "icon_url" => "#{@image_base}/corporations/#{corp_id}/logo?size=64" + } + end + + defp author_line(label, _corp_id), do: %{"name" => label} + + # Prose, not a field grid. Each clause carries its own leading separator and + # returns nil when the underlying data is absent, so an NPC kill simply reads + # "X lost their Y." rather than naming a placeholder attacker. + defp description(kill) do + [ + victim_clause(kill), + final_blow_clause(kill), + top_damage_clause(kill), + others_clause(kill) + ] + |> Enum.reject(&is_nil/1) + |> Enum.join() + |> Kernel.<>(".") + end + + # The notable-items section is PRE-BUDGETED rather than left to `truncate/2`. + # `truncate/2` cuts at an arbitrary grapheme, which on a bullet list emits a + # half-written item name or a bare "โ€ข ". So the base description is truncated + # first, and only whole item lines that still fit are appended. If not even + # one fits, the header is omitted too โ€” never a heading with nothing under it. + # + # A dropped line is silent: the ISK threshold already makes this a top-N, not + # an inventory. The `truncate/2` call stays because it still guards the base. + defp full_description(kill) do + base = truncate(description(kill), @max_description_length) + base <> notable_items_section(kill["notable_items"], String.length(base)) + end + + @notable_items_header "\n\n**Notable Items:**\n" + + defp notable_items_section(items, used) when is_list(items) do + budget = @max_description_length - used - String.length(@notable_items_header) + + case items |> Enum.flat_map(&item_line/1) |> take_fitting(budget) do + [] -> "" + lines -> @notable_items_header <> Enum.join(lines, "\n") + end + end + + defp notable_items_section(_items, _used), do: "" + + defp take_fitting(lines, budget) do + {kept, _used} = + Enum.reduce_while(lines, {[], 0}, fn line, {kept, used} -> + # The +1 is the newline joining this line to the previous one. + needed = String.length(line) + if kept == [], do: 0, else: 1 + + if used + needed <= budget, + do: {:cont, {[line | kept], used + needed}}, + else: {:halt, {kept, used}} + end) + + Enum.reverse(kept) + end + + # Matches the full `NotableItems.item()` shape rather than reading `:value` + # and `:abyssal?` defensively: every key is required by that type, so an item + # missing one is malformed and is dropped rather than rendered half-formed. + # + # Abyssal modules carry no price: their market values are unreliable enough + # that quoting one would be worse than saying nothing, matching + # wanderer-notifier. + defp item_line(%{name: name, quantity: quantity, value: value, abyssal?: abyssal?}) + when is_binary(name) and is_integer(quantity) and quantity > 0 do + count = if quantity > 1, do: " x#{quantity}", else: "" + + price = + if abyssal? do + "" + else + case format_isk(value) do + nil -> "" + isk -> " (~#{isk})" + end + end + + ["โ€ข #{name}#{count}#{price}"] + end + + defp item_line(_item), do: [] + + defp victim_clause(kill) do + pilot = + character_link(kill["victim_char_id"], present(kill["victim_char_name"]) || "Unknown pilot") + + ship = present(kill["victim_ship_name"]) || "Unknown ship" + + case corporation_link(kill["victim_corp_id"], present(kill["victim_corp_ticker"])) do + nil -> "#{pilot} lost their **#{ship}**" + corp -> "#{pilot} (#{corp}) lost their **#{ship}**" + end + end + + defp final_blow_clause(kill) do + case present(kill["final_blow_char_name"]) do + nil -> + nil + + name -> + pilot = character_link(kill["final_blow_char_id"], name) + + case corporation_link(kill["final_blow_corp_id"], present(kill["final_blow_corp_ticker"])) do + nil -> " to #{pilot}" + corp -> " to #{pilot} (#{corp})" + end + end + end + + defp top_damage_clause(kill) do + with name when not is_nil(name) <- present(kill["top_damage_char_name"]), + true <- distinct_from_final_blow?(kill) do + ", top damage by #{character_link(kill["top_damage_char_id"], name)}" + else + _ -> nil + end + end + + # Ids are authoritative when both are present; names are the fallback for + # payloads that carry one without the other. Compared as strings because the + # two ids do not have to arrive as the same type: `collect_ids/2` normalizes + # to integers on the nested branch only, so a flat payload can pair an + # integer with a binary. Matching only `is_integer/1` on both would drop such + # a pair to the name comparison, which is exactly the case the ids exist to + # settle. + defp distinct_from_final_blow?(kill) do + case {kill["final_blow_char_id"], kill["top_damage_char_id"]} do + {fb, td} when (is_integer(fb) or is_binary(fb)) and (is_integer(td) or is_binary(td)) -> + to_string(fb) != to_string(td) + + _ -> + present(kill["final_blow_char_name"]) != present(kill["top_damage_char_name"]) + end + end + + # Relative to whichever pilot(s) got named in the clauses above โ€” final blow, + # top damage, or both. With nobody named at all there is no antecedent for + # "others" to modify, so a wholly anonymous fight (e.g. an NPC final blow + # with no top-damage pilot either) renders no others-clause at all rather + # than a dangling ", and 1 other." An NPC final blow with a *named* + # top-damage pilot still gets an others-clause, since something was named. + defp others_clause(kill) do + named = named_attacker_count(kill) + + if named > 0 do + case kill["attacker_count"] do + count when is_integer(count) and count - named == 1 -> ", and 1 other" + count when is_integer(count) and count - named > 1 -> ", and #{count - named} others" + _ -> nil + end + end + end + + defp named_attacker_count(kill) do + final_blow = if present(kill["final_blow_char_name"]), do: 1, else: 0 + + top_damage = + if present(kill["top_damage_char_name"]) && distinct_from_final_blow?(kill), do: 1, else: 0 + + final_blow + top_damage + end + + defp character_link(id, name) when is_integer(id) or is_binary(id), + do: "**[#{name}](#{@zkill_base}/character/#{id}/)**" + + defp character_link(_id, name), do: "**#{name}**" + + defp corporation_link(_id, nil), do: nil + + defp corporation_link(id, ticker) when is_integer(id) or is_binary(id), + do: "**[#{ticker}](#{@zkill_base}/corporation/#{id}/)**" + + defp corporation_link(_id, ticker), do: "**#{ticker}**" + + defp fields(kill) do + [ + field("Value", format_isk(kill["total_value"]), true), + field("When", relative_time(kill["kill_time"]), true) + ] + |> Enum.reject(&is_nil/1) + end + + defp field(_name, nil, _inline), do: nil + defp field(name, value, inline), do: %{"name" => name, "value" => value, "inline" => inline} + + # `` renders client-side as "3 hours ago", in the reader's own + # timezone. An unparseable kill_time drops the field rather than guessing. + defp relative_time(kill_time) when is_binary(kill_time) do + case DateTime.from_iso8601(kill_time) do + {:ok, datetime, _offset} -> "" + _ -> nil + end + end + + defp relative_time(%DateTime{} = datetime), do: "" + + defp relative_time(%NaiveDateTime{} = naive), + do: relative_time(DateTime.from_naive!(naive, "Etc/UTC")) + + defp relative_time(unix) when is_integer(unix), do: "" + defp relative_time(_), do: nil + + # Selection is on FIELD PRESENCE ONLY. This is *not* a 404 fallback: Discord + # fetches the image itself when it renders the embed, so a failed fetch is + # never observable from here and cannot be reacted to. If the ship type id is + # present we use the ship render even if that render happens not to exist + # upstream; the character portrait is only for kills that carry no ship type. + defp thumbnail(kill) do + cond do + is_integer(kill["victim_ship_type_id"]) or is_binary(kill["victim_ship_type_id"]) -> + %{ + "url" => + "#{@image_base}/types/#{kill["victim_ship_type_id"]}/render?size=#{@thumbnail_size}" + } + + is_integer(kill["victim_char_id"]) or is_binary(kill["victim_char_id"]) -> + %{ + "url" => + "#{@image_base}/characters/#{kill["victim_char_id"]}/portrait?size=#{@thumbnail_size}" + } + + true -> + nil + end + end + + defp footer(kill) do + case kill["killmail_id"] do + nil -> nil + id -> %{"text" => "Killmail ID: #{id}"} + end + end + + defp zkill_url(nil), do: nil + defp zkill_url(id), do: "#{@zkill_base}/kill/#{id}/" + + @doc false + def format_isk(nil), do: nil + def format_isk(0), do: "0 ISK" + + def format_isk(value) when is_number(value) do + Enum.find_value(@isk_units, "#{round(value)} ISK", &format_at_unit(value, &1)) + end + + def format_isk(_), do: nil + + defp format_at_unit(value, {threshold, _divisor, _unit, _next}) when value < threshold, do: nil + + defp format_at_unit(value, {_threshold, divisor, unit, next_unit}) do + case {round_to(value / divisor), next_unit} do + # Top of the table: clamp rather than promote. + {rounded, nil} -> + "#{format_float(rounded)}#{unit} ISK" + + {rounded, next} when rounded >= 1000.0 -> + "#{format_float(round_to(rounded / 1000))}#{next} ISK" + + {rounded, _} -> + "#{format_float(rounded)}#{unit} ISK" + end + end + + # Format a float to avoid scientific notation (e.g., 1.0e3 -> "1000.0") + defp format_float(float) when is_float(float) do + # `:decimals` avoids scientific notation, keeping 1 decimal place + :erlang.float_to_list(float, [{:decimals, 1}]) + |> List.to_string() + end + + defp round_to(float), do: Float.round(float, 1) + + defp present(nil), do: nil + defp present(""), do: nil + defp present(value) when is_binary(value), do: value + defp present(value), do: to_string(value) + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp drop_nils(map), do: Map.reject(map, fn {_k, v} -> is_nil(v) end) +end diff --git a/lib/wanderer_app/external_events/discord/guild.ex b/lib/wanderer_app/external_events/discord/guild.ex new file mode 100644 index 000000000..d45536039 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/guild.ex @@ -0,0 +1,280 @@ +defmodule WandererApp.ExternalEvents.Discord.Guild do + @moduledoc """ + Reads the roles and members of a Discord guild, so the mention pickers can + offer names instead of asking an operator to paste snowflakes. + + ## Why per-guild, and why a picker at all + + Discord renders an unknown role or user mention as inert text: no error, no + warning, just a message that silently pings nobody. Sourcing ids from a + picker scoped to *this destination's* guild โ€” `MapDiscordWebhook.guild_id`, + resolved by `ChannelInfo` โ€” makes that failure structurally impossible. The + installation-wide `DISCORD_GUILD_ID` could not: a map pointed at a different + guild would offer ids that are inert there. + + ## Why not Nostrum + + Nostrum is a dependency, but its gateway only starts when both a bot token + and `DISCORD_GUILD_ID` are configured (`Env.discord_voice_mentions_enabled?/0`), + which would couple a mention picker to voice mentions being set up. + `HttpClient.get/2` is the seam the rest of the Discord integration already + goes through and the one the test suite already stubs. + + ## Degrading + + Both reads need a bot token **and** the bot to be in the guild. Neither is + guaranteed, so `{:error, :no_bot_token}`, `{:error, :unauthorized}` and + `{:error, :forbidden}` are ordinary outcomes rather than faults โ€” the UI + swaps the picker for a manual "add by id" input and says why. They are + returned distinctly because "this instance has no bot" and "your guild has + not invited ours" are different things for an operator to fix. + + Nothing here logs the bot token, and no failure path inspects a response + body โ€” the same discipline `ChannelInfo` documents. + """ + + require Logger + + alias WandererApp.ExternalEvents.Discord.HttpClient + + @type entry :: %{id: String.t(), name: String.t()} + + @cache :api_cache + + # A guild's role list changes on the order of weeks, and a stale entry costs + # nothing worse than a role missing from a picker for a few minutes. Member + # searches are deliberately **not** cached: the query is part of the key, so + # the key space is unbounded and every keystroke would leave an entry behind. + @roles_cache_ttl :timer.minutes(15) + @roles_cache_namespace "discord_guild_roles" + + # Discord caps `limit` at 1000. This is a typeahead: a list longer than the + # dropdown can show is worse than no list, because the entry someone is + # looking for is off the bottom with nothing saying so. + @default_search_limit 25 + @max_search_limit 100 + + # Pinned for the same reason as `ChannelInfo`: the unversioned path follows + # Discord's current default, which has moved under callers before. + @bot_api_base "https://discord.com/api/v10" + + @doc """ + Every mentionable role in `guild_id`, in the order Discord returns them, + cached for #{div(@roles_cache_ttl, 60_000)} minutes. That order is not + specified โ€” the payload carries a `position` field precisely because the + array is not sorted โ€” so treat it as arbitrary rather than meaningful. + + `@everyone` is excluded. Discord returns it as a role whose id *is* the guild + id, and it is the one entry in this list that can wake an entire server up โ€” + offering it one click away from a kill feed toggle is not a picker, it is a + trap. An operator who genuinely wants it can still type the id into the + manual fallback. + + Errors are not cached: a guild that is failing because the bot was just + invited should start working on the next open of the tab, not fifteen + minutes later. + """ + @spec roles(String.t() | nil) :: {:ok, [entry()]} | {:error, term()} + def roles(guild_id) when is_binary(guild_id) do + case cached_roles(guild_id) do + {:ok, roles} -> + {:ok, roles} + + :miss -> + with {:ok, decoded} <- get_json("#{@bot_api_base}/guilds/#{guild_id}/roles", guild_id), + roles when is_list(roles) <- decoded do + entries = + roles + |> Enum.reject(&(&1["id"] == guild_id)) + |> Enum.map(&role_entry/1) + |> Enum.reject(&is_nil/1) + + put_cached_roles(guild_id, entries) + {:ok, entries} + else + {:error, reason} -> {:error, reason} + _not_a_list -> {:error, :unexpected_response} + end + end + end + + def roles(_guild_id), do: {:error, :no_guild} + + @doc """ + Members of `guild_id` whose username or nickname starts with `query`. + + Options: `:limit` (default #{@default_search_limit}, capped at + #{@max_search_limit}). + + A blank query returns `{:ok, []}` without a request โ€” Discord rejects an + empty `query`, and a typeahead asks on every keystroke including the one that + empties the box. + + The displayed name prefers the guild nickname, then the account's display + name, then the username, which is the order Discord's own clients use. + """ + @spec search_members(String.t() | nil, String.t() | nil, keyword()) :: + {:ok, [entry()]} | {:error, term()} + def search_members(guild_id, query, opts \\ []) + + def search_members(guild_id, query, opts) when is_binary(guild_id) and is_binary(query) do + case String.trim(query) do + "" -> + {:ok, []} + + trimmed -> + limit = + opts + |> Keyword.get(:limit, @default_search_limit) + |> clamp(1, @max_search_limit) + + # `URI.encode_query/1` is what makes a query containing a space, an `&` + # or a `#` a value rather than another parameter. + params = URI.encode_query(%{"query" => trimmed, "limit" => limit}) + url = "#{@bot_api_base}/guilds/#{guild_id}/members/search?#{params}" + + with {:ok, decoded} <- get_json(url, guild_id), + members when is_list(members) <- decoded do + {:ok, members |> Enum.map(&member_entry/1) |> Enum.reject(&is_nil/1)} + else + {:error, reason} -> {:error, reason} + _not_a_list -> {:error, :unexpected_response} + end + end + end + + def search_members(guild_id, _query, _opts) when is_binary(guild_id), do: {:ok, []} + def search_members(_guild_id, _query, _opts), do: {:error, :no_guild} + + @doc """ + Whether `reason` means "this instance cannot search this guild" rather than + "this request went wrong". The UI shows its manual-entry fallback for these + and leaves the picker in place for anything else, which is why the callers + ask a named predicate instead of matching atoms at three call sites. + """ + @spec unavailable?(term()) :: boolean() + def unavailable?(reason), do: reason in [:no_bot_token, :no_guild, :unauthorized, :forbidden] + + ## Entry shaping + + defp role_entry(%{"id" => id, "name" => name}) when is_binary(id) do + case present(name) do + nil -> nil + name -> %{id: id, name: name} + end + end + + defp role_entry(_role), do: nil + + defp member_entry(%{"user" => %{"id" => id} = user} = member) when is_binary(id) do + name = + present(member["nick"]) || present(user["global_name"]) || present(user["username"]) + + case name do + nil -> nil + name -> %{id: id, name: name} + end + end + + defp member_entry(_member), do: nil + + ## HTTP + + defp get_json(url, subject) do + with {:ok, token} <- bot_token(), + {:ok, status, body} <- + safe_get(url, [{"authorization", "Bot #{token}"}], subject), + :ok <- check_status(status, subject) do + decode(body, subject) + end + end + + defp bot_token do + case WandererApp.Env.discord_bot_token() do + token when is_binary(token) -> {:ok, token} + _absent -> {:error, :no_bot_token} + end + end + + # 401 and 403 are distinct because they point at different fixes: a bad or + # missing token for the whole instance versus a bot that is simply not in + # this operator's guild. `channel_info.ex` already documents the second as + # normal. + defp check_status(200, _subject), do: :ok + defp check_status(401, _subject), do: {:error, :unauthorized} + defp check_status(403, _subject), do: {:error, :forbidden} + defp check_status(404, _subject), do: {:error, :not_found} + defp check_status(429, _subject), do: {:error, :rate_limited} + + defp check_status(status, subject) do + Logger.debug(fn -> "[Discord.Guild] unexpected status #{status} for guild #{subject}" end) + {:error, {:http_status, status}} + end + + # Same `rescue` **and** `catch` discipline as `ChannelInfo.safe_get/4`: an + # unrescued raise on a keystroke path has already killed this settings tab + # once, and a dead Finch pool exits rather than raising, which `rescue` + # alone does not cover. + defp safe_get(url, headers, subject) do + HttpClient.get(url, headers) + rescue + error -> + Logger.warning( + "[Discord.Guild] request raised for guild #{subject}: #{Exception.message(error)}" + ) + + {:error, :unavailable} + catch + :exit, _reason -> + Logger.warning("[Discord.Guild] request exited for guild #{subject}") + {:error, :unavailable} + end + + defp decode(body, subject) do + case Jason.decode(body) do + {:ok, decoded} -> + {:ok, decoded} + + _ -> + # Never `inspect/1` the body: a proxy in front of Discord can echo the + # request line back, and this request line carries the bot token's + # `Authorization` header's subject โ€” and in the general case a URL. + Logger.debug(fn -> "[Discord.Guild] unparseable response for guild #{subject}" end) + {:error, :unexpected_response} + end + end + + ## Cache + + defp cached_roles(guild_id) do + case Cachex.get(@cache, roles_cache_key(guild_id)) do + {:ok, roles} when is_list(roles) -> {:ok, roles} + _ -> :miss + end + rescue + _error -> :miss + end + + defp put_cached_roles(guild_id, roles) do + Cachex.put(@cache, roles_cache_key(guild_id), roles, ttl: @roles_cache_ttl) + roles + rescue + _error -> roles + end + + defp roles_cache_key(guild_id), do: "#{@roles_cache_namespace}:#{guild_id}" + + ## Helpers + + defp clamp(value, min, max) when is_integer(value), do: value |> max(min) |> min(max) + defp clamp(_value, _min, _max), do: @default_search_limit + + defp present(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp present(_value), do: nil +end diff --git a/lib/wanderer_app/external_events/discord/http_client.ex b/lib/wanderer_app/external_events/discord/http_client.ex new file mode 100644 index 000000000..d1c6fd0a7 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/http_client.ex @@ -0,0 +1,107 @@ +defmodule WandererApp.ExternalEvents.Discord.HttpClient do + @moduledoc """ + Seam over HTTP delivery to Discord, so dispatch logic can be tested without + a live endpoint. The real implementation uses an isolated Finch pool. + """ + + @callback post(url :: String.t(), body :: map()) :: + {:ok, status :: integer(), headers :: list()} | {:error, term()} + + @doc """ + Reads a Discord REST resource. + + Returns the raw response body rather than decoded JSON so this seam stays + transport-only: `ChannelInfo` owns the shape of what it asked for, and a + non-JSON error page from a proxy reaches the caller as data instead of + raising inside the client. + + `headers` carries the bot `Authorization` for guild-scoped reads. It must + stay a parameter and never be read from the environment here โ€” the webhook + identity read (`GET /webhooks/{id}/{token}`) is authorised by the URL alone + and must not carry a bot token it does not need. + """ + @callback get(url :: String.t(), headers :: list()) :: + {:ok, status :: integer(), body :: String.t()} | {:error, term()} + + @doc "Returns the configured implementation module." + def impl do + Application.get_env( + :wanderer_app, + :discord_http_client, + WandererApp.ExternalEvents.Discord.HttpClient.Live + ) + end + + @doc "Posts a Discord message body, delegating to the configured implementation." + def post(url, body), do: impl().post(url, body) + + @doc "Reads a Discord REST resource, delegating to the configured implementation." + def get(url, headers \\ []), do: impl().get(url, headers) + + defmodule Live do + @moduledoc """ + Real HTTP delivery via the isolated Discord Finch pool. + + Named `Live` rather than `Finch` so the nested module does not shadow the + Finch library inside its own body. + """ + @behaviour WandererApp.ExternalEvents.Discord.HttpClient + + @timeout 15_000 + + # Identity reads sit behind a settings screen, not behind a killmail, so + # they get a much shorter leash than delivery: a slow Discord must degrade + # to a masked hint quickly rather than hold a background refresh open for + # the delivery timeout. + @read_timeout 5_000 + + # Checkout must leave room for the read inside the same budget. Finch + # defaults `pool_timeout` to 5_000, which on this pool โ€” shared with the + # far busier delivery path โ€” would let a GET spend @read_timeout waiting + # for a connection and @read_timeout again reading, i.e. double the leash + # the identity read is supposed to have. + @pool_timeout 2_000 + + @impl true + def post(url, body) do + headers = [{"content-type", "application/json"}] + + case Jason.encode(body) do + {:ok, json} -> + :post + |> Finch.build(url, headers, json) + |> Finch.request(WandererApp.Finch.Discord, receive_timeout: @timeout) + |> case do + {:ok, %Finch.Response{status: status, headers: resp_headers}} -> + {:ok, status, resp_headers} + + {:error, reason} -> + {:error, reason} + end + + {:error, reason} -> + {:error, {:encode_failed, reason}} + end + end + + @impl true + def get(url, headers) do + :get + |> Finch.build(url, headers) + |> Finch.request(WandererApp.Finch.Discord, + receive_timeout: @read_timeout, + pool_timeout: @pool_timeout + ) + |> case do + {:ok, %Finch.Response{status: status, body: body}} when is_binary(body) -> + {:ok, status, body} + + {:ok, %Finch.Response{status: status}} -> + {:ok, status, ""} + + {:error, reason} -> + {:error, reason} + end + end + end +end diff --git a/lib/wanderer_app/external_events/discord/matcher.ex b/lib/wanderer_app/external_events/discord/matcher.ex new file mode 100644 index 000000000..dbc59252f --- /dev/null +++ b/lib/wanderer_app/external_events/discord/matcher.ex @@ -0,0 +1,360 @@ +defmodule WandererApp.ExternalEvents.Discord.Matcher do + @moduledoc """ + Decides whether a killmail involves a map's tracked pilots. + + The tracked-pilot set is cached per map because `WandererApp.Map.list_characters/1` + hydrates every character on every call, which is far too expensive to run once + per killmail. + """ + + require Logger + + @cache :discord_notification_cache + # A backstop only: correctness comes from `invalidate_tracked/1`, fired by + # every writer of `map.characters`. The TTL bounds the damage of a missed + # invalidation to five minutes rather than the lifetime of the node. + @ttl :timer.minutes(5) + + # Throttle for the flat-payload warning below. Shares `@cache` because the + # entry is the same kind of thing: derived, per-node, and safe to lose. + @divergence_log_key "discord:attacker-divergence-warned" + @divergence_log_interval :timer.minutes(5) + + @doc """ + The EVE character ids tracked on `map_id`, as **integers**. + + Membership rule: every character registered on the map, whether or not + their location tracker is running. + + `WandererApp.Api.Character`'s `eve_id` is a string; killmail payloads carry + integers. The conversion happens here, once per cache build, so that no + comparison site anywhere else has to think about it. + + Returns `:unavailable` โ€” never an empty `MapSet` โ€” if the map cannot be read + (e.g. its server is not running, or the cache is down). An empty set is a + factual claim that nobody is tracked, and `involvement/3` acts on it: every + kill would be `:not_involved`, which is what *enables* the `excluded_systems` + and `wh_only` filters. With `wh_only` defaulting to true, a moment of cache + unavailability would therefore drop every k-space kill on the map silently. + `:unavailable` is propagated into an `:unknown` verdict instead, which + bypasses those filters and delivers to the system webhook. + + This function never raises. + """ + @spec tracked_eve_ids(String.t()) :: MapSet.t(integer()) | :unavailable + def tracked_eve_ids(map_id) do + case Cachex.get(@cache, cache_key(map_id)) do + {:ok, %MapSet{} = ids} -> + ids + + _ -> + build_and_cache(map_id) + end + rescue + # `Cachex.get/2` and the `Cachex.put/4` in `build_and_cache/1` RAISE against + # an unstarted cache rather than returning an error tuple โ€” the same Cachex + # contract `invalidate_tracked/1` below rescues. This is the read side, and + # its caller is `DiscordDispatcher.partition/3`: letting the raise through + # would lose the entire killmail batch, not just this map's tracked set. + # The documented "never raises" contract above is what makes the + # `:unavailable` fallback safe for every caller. + error -> + Logger.warning( + "[Discord.Matcher] tracked-set cache unavailable for map #{map_id}: #{inspect(error)}" + ) + + :unavailable + end + + @doc """ + Drops the cached set for `map_id`. Must be called by every writer of + `map.characters`. + """ + @spec invalidate_tracked(String.t()) :: :ok + def invalidate_tracked(map_id) do + # Bumping the version inside the same transaction as the delete is what + # makes the delete stick. Without it, a build already in flight would + # `Cachex.put/4` its pre-delete set back afterwards and the stale entry + # would survive the full TTL โ€” the exact failure the invalidation exists to + # prevent. The build re-reads the version under this same lock and discards + # itself if it changed. + Cachex.transaction(@cache, [cache_key(map_id), version_key(map_id)], fn worker -> + Cachex.incr(worker, version_key(map_id), 1) + Cachex.del(worker, cache_key(map_id)) + end) + + :ok + rescue + # Same contract as `DiscordDispatcher.invalidate_cache/1`, which drops the + # same cache: Cachex RAISES against an unstarted cache rather than + # returning an error tuple. Both are called from core map writes + # (`WandererApp.Map`'s three writers of `characters:`), so a context without + # the cache must not turn adding a character into a crash. + _ -> :ok + end + + @doc false + # Public only as a test seam, and only for the `build_fun` argument. + # + # The compare-and-set below is the whole point of `version_key/1`, and it can + # only be exercised by an `invalidate_tracked/1` that lands *after* the + # version read and *before* the write. A real `build/1` completes in + # microseconds and holds no lock a test could queue behind, so there is no + # way to hit that window from the outside; injecting the build makes the + # interleaving exact instead of hoping for it. Production always calls + # `build_and_cache/1`. + def build_and_cache(map_id, build_fun \\ &build/1) do + # Read the version BEFORE building. `build/1` is slow (it hydrates every + # character on the map), so it deliberately runs outside the lock; the + # version read here plus the re-check in `cache_put/3` is what turns that + # into a compare-and-set rather than a blind write. + version = read_version(map_id) + + case build_fun.(map_id) do + {:ok, ids} -> + cache_put(map_id, ids, version) + ids + + :error -> + # Deliberately NOT cached: a transient failure must not be pinned for + # the TTL, or every kill on this map is misrouted for five minutes. + # `:unavailable`, not an empty set โ€” see `tracked_eve_ids/1`. + :unavailable + end + end + + defp read_version(map_id) do + case Cachex.get(@cache, version_key(map_id)) do + {:ok, version} when is_integer(version) -> version + _ -> 0 + end + end + + # Rescued separately from `tracked_eve_ids/1` rather than under its rescue: + # the set has already been built at this point, so a cache that cannot store + # it must still not cost us the answer. Failing to cache is a performance + # problem; returning an empty set would be a routing error. + # + # The write is conditional on the version being unchanged since the build + # started. An `invalidate_tracked/1` that landed mid-build has already bumped + # it, so this build's set is known-stale and is dropped rather than written. + # The caller still returns it for THIS killmail โ€” it was current when the + # build began โ€” but the next killmail rebuilds instead of reading it back. + defp cache_put(map_id, ids, version) do + Cachex.transaction(@cache, [cache_key(map_id), version_key(map_id)], fn worker -> + if read_version_with(worker, map_id) == version do + Cachex.put(worker, cache_key(map_id), ids, ttl: @ttl) + end + end) + + :ok + rescue + _ -> :ok + end + + defp read_version_with(worker, map_id) do + case Cachex.get(worker, version_key(map_id)) do + {:ok, version} when is_integer(version) -> version + _ -> 0 + end + end + + defp build(map_id) do + # `WandererApp.Map.list_characters/1` calls `get_map!/1`, which does not + # raise when the map is absent from `:map_cache` โ€” it logs and returns + # `%{}`, which `list_characters/1` would silently read as "zero + # characters" and we would (wrongly) cache as a valid empty set. Check + # the map's presence explicitly via the non-raising `get_map/1` so a map + # that isn't running is treated as a failed lookup, not a real empty map. + case WandererApp.Map.get_map(map_id) do + {:ok, _map} -> + ids = + map_id + |> WandererApp.Map.list_characters() + # `list_characters/1` can return `nil` entries for character ids on + # the map whose backing record no longer resolves + # (`Character.get_map_character!/2` logs and returns `nil` rather + # than raising). One stale id must cost one pilot, not the whole + # map's set. + |> Enum.reject(&is_nil/1) + |> Enum.map(& &1.eve_id) + |> Enum.map(&parse_eve_id/1) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + {:ok, ids} + + {:error, _reason} -> + # :debug, not :warning. The failure is deliberately not cached (see + # `build_and_cache/1`), so this line runs once per killmail โ€” a busy map + # that is briefly absent from `:map_cache` would flood the log at + # warning level and bury real problems. The routing consequence is + # already conservative and visible in the notifications themselves. + Logger.debug(fn -> + "[Discord.Matcher] Map #{map_id} is not running; no tracked pilots" + end) + + :error + end + rescue + error -> + Logger.warning( + "[Discord.Matcher] Failed to build tracked set for map #{map_id}: #{inspect(error)}" + ) + + :error + end + + defp parse_eve_id(eve_id) when is_integer(eve_id), do: eve_id + + defp parse_eve_id(eve_id) when is_binary(eve_id) do + case Integer.parse(eve_id) do + {id, ""} -> + id + + _ -> + Logger.warning("[Discord.Matcher] Non-numeric eve_id skipped: #{inspect(eve_id)}") + nil + end + end + + defp parse_eve_id(_), do: nil + + defp cache_key(map_id), do: "map:#{map_id}:tracked_eve_ids" + + # Intentionally never expires. It is a monotonic counter, not data: if it + # aged out while a build held an older value, that build's stale set would + # compare equal to the reset counter and be written back. + defp version_key(map_id), do: "map:#{map_id}:tracked_eve_ids:version" + + @type verdict :: + {:involved, :victim} | {:involved, :attacker} | :not_involved | :unknown + + @doc """ + Decides whether a killmail is one the character channel is for. + + ## Which criterion applies + + `focus_corp_ids` **replaces** the tracked-character check rather than widening + it. When it is non-empty, membership of one of those corporations is the only + thing that sends a kill to the character channel; the map's tracked pilots are + not consulted at all, and their kills follow the ordinary system rules. When + it is empty, the map's tracked characters decide, which is the original + behaviour and the default. + + It is deliberately one criterion or the other. A union would mean that turning + the corporation filter on could only ever *add* notifications, so an admin who + wants "the character channel is for my corp, not for whoever happens to be on + the map" would have no way to express it. + + ## Ordering + + Victim checks precede attacker checks, so a kill where both sides match + renders as a *loss*. Losses are the more urgent signal. + + ## Verdicts + + `:not_involved` is a positive finding โ€” we looked and this kill is not ours. + It is what *enables* the `excluded_systems` and `wh_only` filters in + `Router`, so it must never be used to mean "could not tell". That case is + `:unknown`, which bypasses those filters and delivers to the system webhook. + """ + @spec involvement(map(), MapSet.t(integer()) | :unavailable, [integer()]) :: verdict() + def involvement(kill, tracked_eve_ids, focus_corp_ids) when is_list(focus_corp_ids) do + if focus_corp_ids == [] do + character_involvement(kill, tracked_eve_ids) + else + corporation_involvement(kill, focus_corp_ids) + end + end + + # No tracked set to compare against. Answering `:not_involved` here would + # assert that none of the map's pilots were in this fight, which is exactly + # what we failed to determine โ€” and under the default `wh_only` that assertion + # drops the kill. Note the corporation-filter path above never reaches this: + # it does not need the tracked set, so a cache outage does not degrade it. + defp character_involvement(_kill, :unavailable), do: :unknown + + defp character_involvement(kill, tracked_eve_ids) do + if MapSet.member?(tracked_eve_ids, parse_eve_id(kill["victim_char_id"])) do + {:involved, :victim} + else + match_attackers(kill, "attacker_char_ids", &MapSet.member?(tracked_eve_ids, &1)) + end + end + + defp corporation_involvement(kill, focus_corp_ids) do + if parse_eve_id(kill["victim_corp_id"]) in focus_corp_ids do + {:involved, :victim} + else + match_attackers(kill, "attacker_corp_ids", &(&1 in focus_corp_ids)) + end + end + + # ABSENT is not EMPTY. Nested-format payloads always carry the attacker keys + # (possibly as empty lists); flat-format payloads omit them entirely. Treating + # a missing key as `[]` would assert "there were no attackers of ours", which + # we do not know โ€” and the assertion is not free: it means a flat-format + # payload can only ever produce a loss, so *kills* by tracked pilots in + # k-space were dropped outright under the default `wh_only`. Reporting + # `:unknown` costs a kill in the system channel instead of no kill at all. + # + # `attacker_char_ids` / `attacker_corp_ids` are normalized to integers by + # `collect_ids/2` (message_handler.ex) at flatten time โ€” but only on the + # *nested* branch (reached via `add_attacker_identity_data/2`). The flat + # branch returns the payload unmodified and applies no key whitelist and no + # coercion, so if a flat payload ever does carry these keys as binaries + # (nothing today enforces that it can't), `parse_eve_id/1` is the backstop. + # It passes integers straight through, so this costs nothing on the + # already-normalized nested path. + defp match_attackers(kill, key, match_fun) do + if Map.has_key?(kill, "attacker_char_ids") or Map.has_key?(kill, "attacker_corp_ids") do + if Enum.any?(kill[key] || [], &match_fun.(parse_eve_id(&1))), + do: {:involved, :attacker}, + else: :not_involved + else + log_attacker_divergence(kill) + :unknown + end + end + + # At warning, not debug: this is now the reason a kill lands in the system + # channel instead of the character channel, which is user-visible and worth + # explaining. Throttled to one line per interval because it fires per kill, + # and if flat payloads are the norm on some feed it would otherwise be the + # only thing in the log. + defp log_attacker_divergence(kill) do + if divergence_log_allowed?() do + Logger.warning( + "[Discord] killmail #{kill["killmail_id"]}: attacker data absent from payload; " <> + "involvement decided on the victim alone, so kills by tracked pilots are " <> + "reported as :unknown and delivered to the system webhook. " <> + "(throttled to one line per #{div(@divergence_log_interval, 60_000)}m)" + ) + end + + :ok + end + + @doc false + # Exposed only so tests can clear the throttle between cases. A warning + # suppressed by a previous test would make these assertions depend on run + # order, which is exactly the kind of flake that gets a real assertion deleted. + def divergence_log_key, do: @divergence_log_key + + defp divergence_log_allowed?() do + case Cachex.get(@cache, @divergence_log_key) do + {:ok, nil} -> + Cachex.put(@cache, @divergence_log_key, true, ttl: @divergence_log_interval) + true + + _ -> + false + end + rescue + # The cache is the throttle, not the signal. If it is unavailable, log โ€” + # suppressing a warning because the suppression mechanism broke is the + # wrong direction, and a cache that is down is itself already warning here. + _ -> true + end +end diff --git a/lib/wanderer_app/external_events/discord/mentions.ex b/lib/wanderer_app/external_events/discord/mentions.ex new file mode 100644 index 000000000..e834b2c27 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/mentions.ex @@ -0,0 +1,83 @@ +defmodule WandererApp.ExternalEvents.Discord.Mentions do + @moduledoc """ + Renders configured `MapDiscordWebhook.mention_targets` into Discord's two + mention mechanisms: a `content` prefix that actually pings, and the + `allowed_mentions` allowlist that makes doing so safe. + + Deliberately does not observe anything โ€” no voice state, no map presence. + Targets come only from what a map operator configured; see the design + doc's "Why not VoiceParticipants". + """ + + # Guild snowflakes are 17-20 decimal digits. This is the single definition of + # a well-formed mention target; `MapDiscordWebhook.ValidateMentionTargets` + # delegates here. The dependency runs resource -> Mentions and must not be + # reversed: this module stays free of any Ash compile-time dependency. + # + # `\A`/`\z`, NOT `^`/`$`: PCRE's `$` matches before a trailing newline, so + # `^...$` accepts `"user:123456789012345678\n"`. `valid_target?/1` would let + # that through validation and `allowed_mentions/1` โ€” which splits on `":"` + # rather than re-running this regex โ€” would put the newline INSIDE the + # snowflake it hands Discord. + @target_regex ~r/\A(user|role):(\d{17,20})\z/ + + @doc """ + Whether `target` is a well-formed `"user:"` or `"role:"` mention + target. Exposed so callers (and the resource-side validation) can check a + single value without going through the list-shaped functions below. + """ + @spec valid_target?(String.t()) :: boolean() + def valid_target?(target) when is_binary(target), do: Regex.match?(@target_regex, target) + def valid_target?(_), do: false + + @doc """ + Renders `targets` into a `content` prefix: `"user:123"` -> `"<@123>"`, + `"role:456"` -> `"<@&456>"`, joined by spaces. `[]` -> `nil`, so callers can + feed this straight to `VoiceParticipants.prepend_to_messages/2`, whose + no-prefix case is also `nil`. Any entry that fails `valid_target?/1` is + silently dropped rather than raising โ€” malformed data should never turn + into a delivery failure. + """ + @spec prefix([String.t()]) :: String.t() | nil + def prefix([]), do: nil + + def prefix(targets) do + targets + |> Enum.map(&render/1) + |> Enum.reject(&is_nil/1) + |> case do + [] -> nil + rendered -> Enum.join(rendered, " ") + end + end + + @doc """ + Builds the `allowed_mentions` object for a Discord message body. ALWAYS + includes `"parse" => []`, even for `[]` โ€” an empty allowlist with no parse + modes is what makes an unconfigured map safe to post to (see the design + doc's "Mention injection is a real risk"). Invalid entries are dropped, the + same as `prefix/1`. + """ + @spec allowed_mentions([String.t()]) :: map() + def allowed_mentions(targets) do + {users, roles} = + targets + |> Enum.filter(&valid_target?/1) + |> Enum.reduce({[], []}, fn target, {users, roles} -> + case String.split(target, ":", parts: 2) do + ["user", id] -> {[id | users], roles} + ["role", id] -> {users, [id | roles]} + end + end) + + %{"parse" => [], "users" => Enum.reverse(users), "roles" => Enum.reverse(roles)} + end + + defp render(target) do + case Regex.run(@target_regex, target) do + [_, "user", id] -> "<@#{id}>" + [_, "role", id] -> "<@&#{id}>" + _ -> nil + end + end +end diff --git a/lib/wanderer_app/external_events/discord/notable_items.ex b/lib/wanderer_app/external_events/discord/notable_items.ex new file mode 100644 index 000000000..5d5a40922 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/notable_items.ex @@ -0,0 +1,249 @@ +defmodule WandererApp.ExternalEvents.Discord.NotableItems do + @moduledoc """ + Resolves the notable dropped loot for a batch of killmails, for the + **Notable Items** section of the Discord kill embed. + + ## Why this needs network calls at all + + Kills reach us over the wanderer-kills websocket, whose payload carries no + item data โ€” its `Killmail` JSON encoder emits victim, attackers, system, and + zkb metadata only. So the items have to be fetched from ESI using the + killmail hash that *is* in the payload (`zkb.hash`), exactly as + wanderer-notifier does. + + ## Pipeline + + 1. `zkb.hash` โ†’ `get_killmail/2` on ESI. + 2. Flatten `victim.items` **recursively**. A container entry carries its own + nested `items` list; wanderer-notifier reads the list flat and therefore + silently omits loot inside a cargo container. Flattening is a deliberate + improvement over that behaviour, not parity with it. + 3. Keep only entries with a `quantity_dropped` key โ€” destroyed items no + longer exist. Note that dropped-vs-destroyed is the *presence of the key*, + not a value. + 4. Sum quantities per `item_type_id`: the same module fitted to several slots + appears as several entries with distinct `flag`s. + 5. Price through `WandererApp.Market.Triff`, one batched request for the whole + candidate set. + 6. Filter above the ISK threshold, sort descending, take the limit. + 7. **Only then** resolve type names, for the handful of survivors. Names are a + per-type ESI call, so pricing first keeps the common case at zero lookups. + + ## Fail-open + + Every failure path โ€” missing hash, ESI error, market error, unresolvable name + โ€” omits that kill or that item and returns. Nothing raises and nothing + returns `{:error, _}`. A missing section is an acceptable outcome; a missing + kill notification is not. + + ## Test seam + + ESI is resolved through `Application.get_env(:wanderer_app, :esi_client, ...)` + rather than called directly, mirroring `Discord.HttpClient`. Every other ESI + caller in the app still calls `WandererApp.Esi` directly; introducing the seam + app-wide is out of scope for this feature, so a reader will find two idioms. + """ + + require Logger + + alias WandererApp.Market.Triff + + @type item :: %{ + name: String.t(), + quantity: pos_integer(), + value: float(), + abyssal?: boolean() + } + + @doc """ + Returns `%{killmail_id => [item]}` for the kills that have notable loot. + + Kills with no notable loot are **absent from the map**, not present with an + empty list. + """ + @callback enrich([map()]) :: %{optional(term()) => [item()]} + + @doc "Returns the configured enricher, so the dispatcher can inject a stub." + def impl, do: Application.get_env(:wanderer_app, :notable_items_enricher, __MODULE__) + + @spec enrich([map()]) :: %{optional(term()) => [item()]} + def enrich([]), do: %{} + + def enrich(kills) do + dropped = + kills + |> esi_stream(&safe_dropped_quantities/1) + |> Enum.reduce(%{}, fn + {:ok, {killmail_id, quantities}}, acc when map_size(quantities) > 0 -> + Map.put(acc, killmail_id, quantities) + + _result, acc -> + acc + end) + + if map_size(dropped) == 0, do: %{}, else: price_and_select(dropped) + end + + # One ESI round trip per kill, and a batch is up to + # `EmbedFormatter.max_kills_per_event()` kills, so doing this sequentially + # spent the whole enrichment budget on latency: 30 cold fetches at ~100ms + # each cannot fit in 1.5s no matter how fast ESI is. Concurrency is capped so + # a busy batch cannot flood the ESI pool on the dispatcher's behalf. + # + # `on_timeout: :kill_task` rather than letting the stream raise: a single slow + # lookup costs its own item, not the batch. The per-item timeout is the whole + # enrichment budget because the dispatcher kills this task at that point + # anyway โ€” it is a ceiling, not a schedule. + @esi_concurrency 8 + + defp esi_stream(enumerable, fun) do + Task.async_stream(enumerable, fun, + max_concurrency: @esi_concurrency, + timeout: WandererApp.Env.notable_items_timeout_ms(), + on_timeout: :kill_task, + ordered: false + ) + end + + # Wrapped so a raise anywhere in the per-kill path costs that kill only. An + # unrescued exception in a stream child brings down the whole stream, which + # would discard the kills that had already resolved. + defp safe_dropped_quantities(kill), do: safe(fn -> dropped_quantities(kill) end, :skip) + + # -- step 1-4: dropped quantities per kill --------------------------------- + + defp dropped_quantities(kill) do + with killmail_id when not is_nil(killmail_id) <- kill["killmail_id"], + hash when is_binary(hash) <- hash(kill), + {:ok, killmail} <- safe(fn -> esi_client().get_killmail(killmail_id, hash) end, :error) do + {killmail_id, sum_dropped(killmail)} + else + _ -> :skip + end + end + + # Defensive: `MessageHandler.add_core_kill_data/3` retains the whole `zkb` + # map, but the flat-format path may not, and a kill without a hash is simply + # one we cannot enrich. + defp hash(%{"zkb" => %{"hash" => hash}}) when is_binary(hash), do: hash + defp hash(_kill), do: nil + + defp sum_dropped(%{"victim" => %{"items" => items}}) when is_list(items) do + items + |> flatten_items() + |> Enum.reduce(%{}, &add_dropped/2) + end + + defp sum_dropped(_killmail), do: %{} + + # A container is itself dropped loot, so it is kept alongside its contents. + defp flatten_items(items) when is_list(items) do + Enum.flat_map(items, fn + %{"items" => nested} = item when is_list(nested) -> [item | flatten_items(nested)] + item -> [item] + end) + end + + defp flatten_items(_items), do: [] + + defp add_dropped(%{"item_type_id" => type_id, "quantity_dropped" => quantity}, acc) + when is_integer(type_id) and is_integer(quantity) and quantity > 0, + do: Map.update(acc, type_id, quantity, &(&1 + quantity)) + + defp add_dropped(_item, acc), do: acc + + # -- step 5-7: price, select, name ----------------------------------------- + + defp price_and_select(dropped) do + type_ids = dropped |> Map.values() |> Enum.flat_map(&Map.keys/1) |> Enum.uniq() + + case safe(fn -> Triff.quote_types(type_ids) end, :error) do + {:ok, prices} -> select_and_name(dropped, prices) + _ -> %{} + end + end + + defp select_and_name(dropped, prices) do + threshold = WandererApp.Env.notable_items_threshold_isk() + limit = WandererApp.Env.notable_items_limit() + + selected = + dropped + |> Enum.map(fn {killmail_id, quantities} -> + {killmail_id, select(quantities, prices, threshold, limit)} + end) + |> Enum.reject(fn {_killmail_id, selected} -> selected == [] end) + + names = resolve_names(selected) + + selected + |> Enum.map(fn {killmail_id, selected} -> {killmail_id, to_items(selected, names)} end) + |> Enum.reject(fn {_killmail_id, items} -> items == [] end) + |> Map.new() + end + + defp select(quantities, prices, threshold, limit) do + quantities + |> Enum.flat_map(fn {type_id, quantity} -> + case Map.get(prices, type_id) do + nil -> [] + unit_price -> [{type_id, quantity, unit_price * quantity}] + end + end) + |> Enum.filter(fn {_type_id, _quantity, value} -> value > threshold end) + |> Enum.sort_by(fn {_type_id, _quantity, value} -> value end, :desc) + |> Enum.take(limit) + end + + defp resolve_names(selected) do + selected + |> Enum.flat_map(fn {_killmail_id, items} -> + Enum.map(items, fn {type_id, _quantity, _value} -> type_id end) + end) + |> Enum.uniq() + |> esi_stream(&resolve_name/1) + |> Enum.reduce(%{}, fn + {:ok, {type_id, name}}, acc -> Map.put(acc, type_id, name) + _result, acc -> acc + end) + end + + defp resolve_name(type_id) do + case safe(fn -> esi_client().get_type_info(type_id) end, :error) do + {:ok, %{"name" => name}} when is_binary(name) -> {type_id, name} + _ -> :unresolved + end + end + + # An item whose name would not resolve is dropped: there is nothing to render. + defp to_items(selected, names) do + Enum.flat_map(selected, fn {type_id, quantity, value} -> + case Map.get(names, type_id) do + nil -> [] + name -> [%{name: name, quantity: quantity, value: value, abyssal?: abyssal?(name)}] + end + end) + end + + defp abyssal?(name), do: name |> String.downcase() |> String.starts_with?("abyssal") + + # -- plumbing -------------------------------------------------------------- + + defp esi_client, do: Application.get_env(:wanderer_app, :esi_client, WandererApp.Esi) + + # Fail-open belt-and-braces. The dispatcher runs this module under + # `async_nolink` so a crash would not take it down, but an exception here + # would still cost the whole batch its section โ€” including the kills that had + # already resolved fine. + defp safe(fun, fallback) do + fun.() + rescue + error -> + Logger.warning("[NotableItems] #{Exception.message(error)}") + fallback + catch + :exit, reason -> + Logger.warning("[NotableItems] exited: #{inspect(reason)}") + fallback + end +end diff --git a/lib/wanderer_app/external_events/discord/route_watcher.ex b/lib/wanderer_app/external_events/discord/route_watcher.ex new file mode 100644 index 000000000..cd34086ea --- /dev/null +++ b/lib/wanderer_app/external_events/discord/route_watcher.ex @@ -0,0 +1,468 @@ +defmodule WandererApp.ExternalEvents.Discord.RouteWatcher do + @moduledoc """ + One GenServer per map: owns the debounce timer, the last-known route state, + its `config_version`, and the in-flight solver task. Registry-addressed like + `Discord.Worker` (`worker.ex`), keyed by `map_id` instead of `webhook_id`. + + ## Why the solver task never blocks this process + + `Task.yield(20_000) || Task.shutdown(:brutal_kill)` โ€” the idiom + `DiscordDispatcher`'s enrichment steps use โ€” is wrong here. It parks this + process for up to 20s, during which it cannot receive the `notify` casts that + are supposed to set the re-run flag. Those casts would sit in the mailbox and + be processed *after* the stale result was already published, so a connection + closing mid-solve could still produce a false "opened" alert. The dispatcher + can afford to block because it is enriching a payload it already holds; this + process cannot, because incoming events invalidate the work in flight. + + So the task runs via `Task.Supervisor.async_nolink/2`, its ref (inside the + `%Task{}` struct, not bare) is stored in state, and both `{ref, result}` and + `{:DOWN, ref, ...}` are handled in `handle_info`. A `Process.send_after/3` + deadline enforces the 20s budget from the timeout handler, calling + `Task.shutdown(task, :brutal_kill)` โ€” which itself drains the matching `:DOWN` + or `{ref, result}` message, so no separate cleanup clause is needed for a + self-inflicted shutdown. + + A notify arriving while a task is in flight only sets `rerun?: true`; the + result handler discards the in-flight answer and starts a fresh evaluation + immediately when it lands with `rerun?` set, rather than publishing a result + that may already be stale. + + ## What `rehydrate/1` does and does not survive + + `persist/1` writes to `:discord_route_alert_cache`, a plain in-memory Cachex + table with no TTL and no disk backing. Route-alert state therefore survives + only a **watcher process restart** (a crash, or a supervisor restart) on a + running node. It does NOT survive a node restart or a deployment โ€” the cache + starts empty, `rehydrate/1` finds nothing, and every watcher begins at + `:unknown`. + + The visible consequence: a route that was already open before a restart is + announced again as `:opened` on the first evaluation after it, because + `:unknown -> {:qualifying, _}` is the "opened" transition. That is the + deliberate trade โ€” the alternative is persisting alert state to the database + on every evaluation to suppress one duplicate message per map per deploy. + """ + + use GenServer, restart: :transient + + require Logger + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.ExternalEvents.Discord.{Router, WorkerSupervisor, EmbedFormatter} + alias WandererApp.Map.RouteAlert.Evaluator + + @registry WandererApp.ExternalEvents.Discord.RouteWatcherRegistry + @cache :discord_route_alert_cache + + @debounce_ms 10_000 + @ceiling_ms 60_000 + @task_timeout_ms 20_000 + + def start_link(opts) do + map_id = Keyword.fetch!(opts, :map_id) + GenServer.start_link(__MODULE__, opts, name: via(map_id)) + end + + @doc "Queues a re-evaluation for this map. The watcher must already be running." + @spec notify(binary()) :: :ok + def notify(map_id) do + GenServer.cast(via(map_id), :notify) + end + + @doc "The Registry this module is addressed through. Owned here, read by RouteWatcherSupervisor." + def registry, do: @registry + + defp via(map_id), do: {:via, Registry, {@registry, map_id}} + + @impl true + def init(opts) do + map_id = Keyword.fetch!(opts, :map_id) + + state = %{ + map_id: map_id, + route_state: :unknown, + config_version: nil, + timer_ref: nil, + first_notify_at: nil, + task: nil, + task_deadline_ref: nil, + rerun?: false, + pending_notification: nil, + debounce_ms: Keyword.get(opts, :debounce_ms, @debounce_ms), + ceiling_ms: Keyword.get(opts, :ceiling_ms, @ceiling_ms), + task_timeout_ms: Keyword.get(opts, :task_timeout_ms, @task_timeout_ms) + } + + {:ok, rehydrate(state)} + end + + # Only the raw {route_state, config_version} pair is rehydrated here. The + # config_version comparison against the map's CURRENT configuration happens + # in start_evaluation/1 on the next notify, exactly as it does on every other + # evaluation โ€” deferring it avoids a DB read on every process start for + # watchers that are started but never fire (e.g. a crash-restart loop). + defp rehydrate(state) do + case Cachex.get(@cache, state.map_id) do + {:ok, %{route_state: rs, config_version: cv}} -> + %{state | route_state: rs, config_version: cv} + + _ -> + state + end + rescue + # Cache not started in every test context; a fresh :unknown state is the + # correct fallback, not a crash. + _ -> state + end + + @impl true + def handle_cast(:notify, %{task: task} = state) when not is_nil(task) do + {:noreply, %{state | rerun?: true}} + end + + def handle_cast(:notify, state) do + {:noreply, arm_timer(state)} + end + + defp arm_timer(state) do + now = System.monotonic_time(:millisecond) + first_notify_at = state.first_notify_at || now + + if state.timer_ref, do: Process.cancel_timer(state.timer_ref) + + # Re-armed to the full debounce on every notify, but never pushed past the + # ceiling measured from the FIRST notify of this burst โ€” otherwise a chain + # under continuous scanning (a notify at least once every debounce_ms) + # would never evaluate at all. + remaining_to_ceiling = first_notify_at + state.ceiling_ms - now + delay = min(state.debounce_ms, max(remaining_to_ceiling, 0)) + + timer_ref = Process.send_after(self(), :evaluate, delay) + %{state | timer_ref: timer_ref, first_notify_at: first_notify_at} + end + + @impl true + def handle_info(:evaluate, state) do + state = %{state | timer_ref: nil, first_notify_at: nil} + {:noreply, start_evaluation(state)} + end + + # -- the result -------------------------------------------------------------- + + def handle_info({ref, result}, %{task: %Task{ref: ref}} = state) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + if state.task_deadline_ref, do: Process.cancel_timer(state.task_deadline_ref) + state = %{state | task: nil, task_deadline_ref: nil} + {:noreply, land_result(state, result)} + end + + # The task crashed outright (not our own :brutal_kill โ€” that path is handled + # entirely inside Task.shutdown/2 in the timeout handler below and never + # reaches here). Treated the same as a solver error: keep state, log, emit + # telemetry, do not alert. + def handle_info({:DOWN, ref, :process, _pid, reason}, %{task: %Task{ref: ref}} = state) + when is_reference(ref) do + if state.task_deadline_ref, do: Process.cancel_timer(state.task_deadline_ref) + state = %{state | task: nil, task_deadline_ref: nil} + {:noreply, land_result(state, {:error, reason})} + end + + # A late reply for a task we already shut down or whose deadline already + # fired for a *different* in-flight task (map restarted evaluation). + def handle_info({ref, _result}, state) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + {:noreply, state} + end + + def handle_info({:DOWN, ref, :process, _pid, _reason}, state) when is_reference(ref) do + {:noreply, state} + end + + # -- the 20s solve deadline --------------------------------------------------- + + # Task.yield(20_000) || Task.shutdown(:brutal_kill) is deliberately NOT used + # here โ€” see the moduledoc. This handler is the alternative: a self-scheduled + # message fires the deadline instead of a blocking wait, so the mailbox (and + # therefore `notify/1`) stays live for the entire 20s. + def handle_info({:task_timeout, ref}, %{task: %Task{ref: ref}} = state) do + Task.shutdown(state.task, :brutal_kill) + + Logger.warning( + "[Discord.RouteWatcher] route solve exceeded #{state.task_timeout_ms}ms for map #{state.map_id}; killed" + ) + + emit_telemetry(state, :timeout) + state = %{state | task: nil, task_deadline_ref: nil} + + state = + if state.rerun? do + start_evaluation(%{state | rerun?: false}) + else + state + end + + {:noreply, state} + end + + # A deadline message for a task that already finished or was already killed โ€” + # its :task_timeout was cancelled, but cancellation is not guaranteed to beat + # a message already in the mailbox. Harmless no-op. + def handle_info({:task_timeout, _stale_ref}, state), do: {:noreply, state} + + # Anything else (stray messages, unexpected sends) โ€” log and keep running + # rather than crashing this long-lived per-map process, mirroring + # `Discord.Worker`'s own catch-all (`worker.ex:182-184`). + def handle_info(msg, state) do + Logger.debug("[Discord.RouteWatcher] unexpected message: #{inspect(msg)}") + {:noreply, state} + end + + # -- launching a solve ------------------------------------------------------ + + defp start_evaluation(state) do + case load_notification(state.map_id) do + {:ok, notification} -> start_evaluation(state, notification) + :error -> state + end + end + + defp start_evaluation(state, notification) do + cv = config_version(notification) + + # A config change discards stored state to :unknown rather than comparing + # against a state that describes a different question ("State identity is + # versioned by config" in the design doc). + state = + if cv != state.config_version do + %{state | route_state: :unknown, config_version: cv} |> persist() + else + state + end + + if notification.route_alerts_enabled? and not is_nil(notification.home_system_id) do + launch_task(state, notification) + else + # Disabling clears outright. Re-enabling then starts from :none, which + # the transition table treats identically to :unknown โ€” the next + # qualifying result posts "opened" either way, so no special case. + %{state | route_state: :none, config_version: cv, pending_notification: nil} + |> persist() + end + end + + defp launch_task(state, notification) do + if Process.whereis(WandererApp.ExternalEvents.Discord.TaskSupervisor) do + task = + Task.Supervisor.async_nolink( + WandererApp.ExternalEvents.Discord.TaskSupervisor, + fn -> + solver_impl().find_strict( + notification.map_id, + [Integer.to_string(Evaluator.jita_system_id())], + Integer.to_string(notification.home_system_id), + Evaluator.solver_settings(), + false + ) + end + ) + + deadline_ref = Process.send_after(self(), {:task_timeout, task.ref}, state.task_timeout_ms) + + %{ + state + | task: task, + task_deadline_ref: deadline_ref, + rerun?: false, + pending_notification: notification + } + else + # `Discord.TaskSupervisor` is only started when webhooks are globally + # enabled (see `application.ex`'s `maybe_start_external_events_services/0`), + # so a route-alert-enabled map can still land here if that toggle is off + # or the supervisor hasn't come up yet. Skip this cycle rather than + # crashing on `Task.Supervisor.async_nolink/2`'s `:noproc` exit โ€” the + # next `notify/1` will retry via the normal debounce path. + Logger.warning( + "[Discord.RouteWatcher] Discord.TaskSupervisor not running; skipping route solve for map #{state.map_id}" + ) + + state + end + end + + defp solver_impl, + do: Application.get_env(:wanderer_app, :route_alert_solver, WandererApp.Map.Routes) + + # Overridable the same way as solver_impl/0, so a test can script a delivery + # failure (e.g. the general {:error, reason} branch below) without depending + # on the real WorkerSupervisor's DynamicSupervisor rejecting a start for some + # reason. Production always uses the real WorkerSupervisor. + defp worker_supervisor_impl, + do: Application.get_env(:wanderer_app, :route_alert_worker_supervisor, WorkerSupervisor) + + defp load_notification(map_id) do + with {:ok, notification} when not is_nil(notification) <- + MapDiscordNotification.by_map(map_id), + {:ok, notification} <- Ash.load(notification, :webhooks) do + {:ok, notification} + else + _ -> :error + end + end + + defp land_result(state, result) do + if state.rerun? do + # A topology change arrived mid-solve: this answer no longer describes + # the current chain. Discard it and start a fresh solve immediately + # rather than waiting out another debounce window โ€” the coalescing + # already happened via the flag. + start_evaluation(%{state | rerun?: false}) + else + notification = state.pending_notification + outcome = Evaluator.evaluate(result, max_jumps: notification.route_max_jumps) + state = %{state | pending_notification: nil} + transition(state, notification, outcome) + end + end + + # -- the transition table ----------------------------------------------------- + + defp transition(state, _notification, :unknown) do + emit_telemetry(state, :unknown) + persist(state) + end + + defp transition(state, _notification, :none) do + emit_telemetry(state, :none) + persist(%{state | route_state: :none}) + end + + defp transition(%{route_state: prev} = state, notification, {:qualifying, %{jumps: jumps} = q}) do + case prev do + p when p in [:unknown, :none] -> alert(state, notification, :opened, q, jumps, nil) + {:qualifying, old} when jumps < old -> alert(state, notification, :improved, q, jumps, old) + {:qualifying, _old} -> persist(%{state | route_state: {:qualifying, jumps}}) + end + end + + # State is written BEFORE delivery, matching DiscordDispatcher's + # at-most-once posture (`handle_delivery_result/4`): a delivery failure loses + # one alert rather than repeating it. `{:error, :not_running}` means nothing + # was enqueued, so the write is reverted exactly as the dispatcher does. + # `previous_jumps` is the jump count this route is improving on, and is nil + # for `:opened` (there is no prior qualifying route to compare against). It + # exists only so the embed can say "7 โ†’ 2 jumps" instead of "2 jumps": the + # delta is what makes an `:improved` alert worth reading, and the transition + # table above is the only place that still knows it. + defp alert(state, notification, kind, qualifying, jumps, previous_jumps) do + # `state` still carries the PREVIOUS route_state here โ€” captured as + # `prev_state` before the optimistic write, so a reverted delivery + # restores exactly what was there before this transition, not the new + # value we are about to persist. + prev_state = state + new_state = persist(%{state | route_state: {:qualifying, jumps}}) + + case Router.route_destination(notification) do + {:ok, webhook} -> + deliver_alert( + new_state, + prev_state, + notification, + webhook, + kind, + qualifying, + jumps, + previous_jumps + ) + + # Also "nothing was enqueued", so it reverts exactly like + # `deliver_alert/8`'s {:error, :not_running}. Keeping the optimistic + # write here would mean the same route at the same jump count takes the + # silent `{:qualifying, _old}` branch forever once the destination is + # usable again, and is never announced. + :drop -> + persist(prev_state) + end + end + + defp deliver_alert( + state, + prev_state, + notification, + webhook, + kind, + qualifying, + jumps, + previous_jumps + ) do + alert = %{ + kind: kind, + jumps: jumps, + previous_jumps: previous_jumps, + path: qualifying.path, + exit_system: qualifying.exit_system, + map_id: state.map_id, + home_system_id: notification.home_system_id + } + + messages = EmbedFormatter.format_route_alert(alert, mention_targets: webhook.mention_targets) + + case worker_supervisor_impl().deliver(webhook.id, messages) do + :ok -> + emit_telemetry(state, kind) + state + + # Nothing was enqueued: revert the optimistic write to what it was + # before this transition, mirroring `handle_delivery_result/4`'s + # `{:error, :not_running}` clause in the dispatcher. + {:error, :not_running} -> + persist(%{state | route_state: prev_state.route_state}) + + # Any other enqueue failure โ€” mirrors `discord_dispatcher.ex:740`'s + # catch-all: log and revert the same way, rather than raising a + # `CaseClauseError` on a reason this `case` didn't anticipate. + {:error, reason} -> + Logger.warning( + "[Discord.RouteWatcher] route alert delivery enqueue failed for map #{state.map_id}: #{inspect(reason)}" + ) + + persist(%{state | route_state: prev_state.route_state}) + end + end + + defp emit_telemetry(state, outcome) do + :telemetry.execute( + [:wanderer_app, :discord, :route_alert], + %{count: 1}, + %{map_id: state.map_id, outcome: outcome} + ) + end + + defp persist(state) do + Cachex.put(@cache, state.map_id, %{ + route_state: state.route_state, + config_version: state.config_version + }) + + state + rescue + _ -> state + end + + # -- config identity --------------------------------------------------------- + + @doc """ + Hashes the configuration that a stored route_state's meaning depends on. + A mismatch on rehydrate or on any evaluation means the stored value describes + a different question, and is discarded rather than compared + ("State identity is versioned by config" in the design doc). + """ + @spec config_version(struct()) :: binary() + def config_version(%{home_system_id: home_system_id, route_max_jumps: route_max_jumps}) do + {home_system_id, route_max_jumps, Evaluator.solver_settings()} + |> :erlang.term_to_binary() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end +end diff --git a/lib/wanderer_app/external_events/discord/route_watcher_supervisor.ex b/lib/wanderer_app/external_events/discord/route_watcher_supervisor.ex new file mode 100644 index 000000000..befed8bc3 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/route_watcher_supervisor.ex @@ -0,0 +1,130 @@ +defmodule WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor do + @moduledoc """ + Starts one `Discord.RouteWatcher` per map on demand, addressed through the + Registry `RouteWatcher` owns (`RouteWatcher.registry/0`). + + Unlike `Discord.WorkerSupervisor` (whose moduledoc this otherwise mirrors), + this supervisor does NOT declare the Registry as its own child: that + Registry is started unconditionally in `application.ex`'s `core_children` + (alongside `:discord_route_alert_cache`) so `RouteWatcher`'s own tests can + start a bare watcher without this supervisor at all โ€” declaring it again + here would collide with that already-running process. Consequently the + liveness guard below keys off the `DynamicSupervisor` this module DOES own, + not the Registry, which is always up independently of whether this + supervisor is started. + + Only started when webhooks are globally enabled (`application.ex`), so + `notify/1` and `stop_watcher/1` guard `Process.whereis/1` exactly as + `WorkerSupervisor` does โ€” a no-op when this tree is not running, never a + crash, so callers on the dispatch and resource-destroy paths do not need to + know whether the feature is enabled. + """ + + use Supervisor + + require Logger + + alias WandererApp.ExternalEvents.Discord.RouteWatcher + + @dyn_sup WandererApp.ExternalEvents.Discord.RouteWatcherDynamicSupervisor + # Same cache RouteWatcher.persist/1 and rehydrate/1 read and write + # (route_watcher.ex:41). Named directly rather than through an accessor + # because RouteWatcher does not expose one โ€” application.ex names this same + # atom directly too when declaring the Cachex worker. + @cache :discord_route_alert_cache + @stop_timeout_ms 5_000 + + def start_link(opts \\ []), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + children = [ + {DynamicSupervisor, name: @dyn_sup, strategy: :one_for_one} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + @doc "Starts the map's watcher if needed, then forwards the notify." + @spec notify(binary()) :: :ok + def notify(map_id) do + # Both the dyn sup (owned here) and the Registry (owned by RouteWatcher, + # started unconditionally elsewhere โ€” see the moduledoc) must be checked: + # Registry.lookup/2 raises ArgumentError on an unregistered name, and the + # two processes' lifecycles are independent, so either one being absent + # must degrade to a no-op rather than a crash. + if running?() do + case ensure_watcher(map_id) do + {:ok, _pid} -> + RouteWatcher.notify(map_id) + + # This is the feature's only production trigger, so a watcher that + # cannot start means the map never alerts again with nothing in the + # logs to find. WorkerSupervisor.deliver/2 logs the same case. + {:error, reason} -> + Logger.warning( + "[Discord] could not start route watcher for map #{map_id}: #{inspect(reason)}" + ) + end + end + + :ok + end + + @doc """ + Stops the map's watcher if one is running, and evicts its cached + route_state/config_version so a subsequent watcher for the same map starts + fresh at `:unknown` instead of rehydrating stale state (the TTL-less + `:discord_route_alert_cache` otherwise outlives the process indefinitely). + """ + @spec stop_watcher(binary()) :: :ok + def stop_watcher(map_id) do + if running?() do + case Registry.lookup(RouteWatcher.registry(), map_id) do + [{pid, _}] -> try_stop(pid) + [] -> :ok + end + end + + evict_cache(map_id) + :ok + end + + defp running?, do: Process.whereis(@dyn_sup) && Process.whereis(RouteWatcher.registry()) + + defp try_stop(pid) do + GenServer.stop(pid, :normal, @stop_timeout_ms) + catch + :exit, _ -> :ok + end + + # Defensive the same way RouteWatcher.persist/1 and rehydrate/1 are: the + # cache is not started in every test context, and a missing cache must not + # turn a stop into a crash. + defp evict_cache(map_id) do + Cachex.del(@cache, map_id) + :ok + rescue + _ -> :ok + end + + defp ensure_watcher(map_id) do + case Registry.lookup(RouteWatcher.registry(), map_id) do + [{pid, _}] when is_pid(pid) -> + if Process.alive?(pid), do: {:ok, pid}, else: start_watcher(map_id) + + [] -> + start_watcher(map_id) + end + end + + defp start_watcher(map_id) do + spec = {RouteWatcher, map_id: map_id} + + case DynamicSupervisor.start_child(@dyn_sup, spec) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + error -> error + end + end +end diff --git a/lib/wanderer_app/external_events/discord/router.ex b/lib/wanderer_app/external_events/discord/router.ex new file mode 100644 index 000000000..a4befbdb4 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/router.ex @@ -0,0 +1,124 @@ +defmodule WandererApp.ExternalEvents.Discord.Router do + @moduledoc """ + Chooses the destination webhook for a single killmail. + + Rules, evaluated in order (design section 4): + + | # | Condition | Destination | + |---|----------------------------------------------------|-------------------| + | 1 | System in `excluded_systems`, verdict `:not_involved` | drop | + | 2 | `wh_only` on, system not a wormhole, `:not_involved` | drop | + | 3 | `{:involved, _}` | character webhook | + | 4 | Otherwise (`:not_involved` past 1-2, or `:unknown`) | system webhook | + + Rules 1 and 2 are carve-outs: a kill involving the pilots or corporations the + character channel is for is always interesting, wherever it happened, so the + exclusion and wormhole-only filters do not apply to it. + + ## `:unknown` is not `:not_involved` + + Only `:not_involved` โ€” a positive finding that this kill is not ours โ€” enables + rules 1 and 2. `:unknown` means `Matcher` could not determine involvement + (the tracked-character set was unavailable, or the payload carried no attacker + data). It bypasses rules 1 and 2 and lands on the **system** webhook. + + This distinction is the whole point. Folding `:unknown` into `:not_involved` + would mean a cache outage silently drops every k-space kill on a map with the + default `wh_only`, and the only trace is one log line. Delivering a kill to + the system channel that a filter might have excluded is the cheaper error. + + ## Fallback + + When no `:character` webhook row exists, rule 3 resolves to the **system** + webhook. Every existing single-webhook configuration therefore keeps working + with no user action, and the character channel is purely opt-in. + + ## Disabled destinations drop; they do not reroute + + If the webhook a kill routes to is itself disabled โ€” by the user or by the + consecutive-failure threshold โ€” the kill is dropped, **not** sent to the other + channel. Disabling a channel must mean silence for that class of kill, not + silent misdirection into a channel the user did not choose. For a public + character channel that is a privacy question, not just a preference. + + Do not "fix" this into a reroute. `RouterTest` asserts it deliberately. + + ## Route alerts have their own destination, and no fallback + + `route_destination/1` resolves a route alert to the `:route` webhook and + nothing else. Unlike rule 3's `:character` โ†’ `:system` fallback, a missing + `:route` row drops. + + A route alert *is* the chain topology: the Path field names every system a + scout has found, in order, from the map's home system to Jita. A channel the + user configured to receive killmails has not consented to receive that. The + fallback would hand full chain topology to a channel chosen for a different + and much less sensitive purpose, with no user action and no way to notice โ€” + which makes "no configuration change needed" a leak, not a convenience. + Route alerts are opt-in by configuring a `:route` webhook. + + This is the same reasoning as the disabled-drops rule above: a destination + the user did not pick for *this* message is misdirection, and silence is the + safe failure. A configured-but-disabled `:route` webhook drops for the same + reason a missing one does. + """ + + alias WandererApp.SystemClass + + @type verdict :: WandererApp.ExternalEvents.Discord.Matcher.verdict() + + @doc """ + Resolves one killmail to a destination. `notification` must have `:webhooks` + loaded. + """ + @spec route(map(), struct(), verdict()) :: {:ok, struct()} | :drop + def route(kill, notification, verdict) do + involved? = match?({:involved, _}, verdict) + # Only a positive "this kill is not ours" opens the filters. `:unknown` must + # not, or an undetermined verdict is indistinguishable from an excluded one. + filterable? = verdict == :not_involved + system_id = kill["solar_system_id"] + + cond do + filterable? and system_id in (notification.excluded_systems || []) -> + :drop + + filterable? and notification.wh_only and not SystemClass.wormhole_system?(system_id) -> + :drop + + involved? -> + # Fallback to the system webhook when the character channel is not + # configured at all. `nil` here means "not configured"; a configured but + # disabled row is a different thing and is handled by `usable/1`. + usable(webhook(notification, :character) || webhook(notification, :system)) + + true -> + usable(webhook(notification, :system)) + end + end + + @doc """ + Resolves a route alert to a destination. `notification` must have + `:webhooks` loaded. + + No fallback: without a `:route` webhook this drops. See the moduledoc. + """ + @spec route_destination(struct()) :: {:ok, struct()} | :drop + def route_destination(notification) do + usable(webhook(notification, :route)) + end + + # Guarded on `is_list`: `:webhooks` is a relationship, so an unloaded + # notification carries `%Ash.NotLoaded{}` here. Reading `.role` off that would + # raise on the dispatch path; treating it as "no destination" drops instead, + # which is the conservative direction. + defp webhook(%{webhooks: webhooks}, role) when is_list(webhooks) do + Enum.find(webhooks, &(&1.role == role)) + end + + defp webhook(_notification, _role), do: nil + + defp usable(nil), do: :drop + defp usable(%{enabled?: false}), do: :drop + defp usable(webhook), do: {:ok, webhook} +end diff --git a/lib/wanderer_app/external_events/discord/system_name.ex b/lib/wanderer_app/external_events/discord/system_name.ex new file mode 100644 index 000000000..05da13ec2 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/system_name.ex @@ -0,0 +1,90 @@ +defmodule WandererApp.ExternalEvents.Discord.SystemName do + @moduledoc """ + Resolves the system name shown on a killmail embed, per destination role. + + Map-local system names (`temporary_name`, then `custom_name`) appear on the + system webhook only. The character webhook always shows the canonical EVE name. + + This is a privacy boundary, not a formatting preference. Corporations commonly + keep the character-kill channel public so members without map access can see + kills and losses. Map-local chain naming in that channel leaks the map's + private naming to people who were deliberately not granted map access, and a + message posted to a public channel cannot be recalled. + + Resolution order on the system webhook: `temporary_name` -> `custom_name` -> + canonical name. + + This rule looks like an inconsistency and will invite a "fix." It gets a + regression test named for the constraint, and this paragraph is the reason a + reviewer should find when they go looking. + """ + + require Logger + + alias WandererApp.Api.MapSystem + + @type role :: :system | :character | :route + + @doc """ + The system name to render for `role`. + + Returns `nil` when no name can be resolved at all; the formatter renders + "Unknown system" in that case rather than guessing. + """ + @spec display_name(String.t(), integer(), role()) :: String.t() | nil + def display_name(_map_id, solar_system_id, :character), do: canonical_name(solar_system_id) + + def display_name(map_id, solar_system_id, :system) do + map_local_name(map_id, solar_system_id) || canonical_name(solar_system_id) + end + + # Route alerts carry the same map-local-names privacy boundary as :system: + # the whole message is the map's own chain, so the resolution order matches + # :system exactly rather than falling through to :character's canonical-only + # behavior. + def display_name(map_id, solar_system_id, :route) do + map_local_name(map_id, solar_system_id) || canonical_name(solar_system_id) + end + + defp map_local_name(map_id, solar_system_id) + when is_binary(map_id) and is_integer(solar_system_id) do + # NOTE: `read_by_map_and_solar_system`, not `by_map_id_and_solar_system_id`. + # The latter targets the primary `:read` action, whose + # `FilterSystemsByActorMap` preparation filters to nothing when there is no + # actor in context โ€” and there never is one here, because the dispatcher + # runs from a GenServer. It would return nil for every system and silently + # collapse the two roles into one. + case MapSystem.read_by_map_and_solar_system(%{ + map_id: map_id, + solar_system_id: solar_system_id + }) do + {:ok, %{} = system} -> + present(system.temporary_name) || present(system.custom_name) + + _ -> + nil + end + rescue + error -> + Logger.debug(fn -> + "[SystemName] map-local lookup failed for #{map_id}/#{solar_system_id}: #{inspect(error)}" + end) + + nil + end + + defp map_local_name(_map_id, _solar_system_id), do: nil + + defp canonical_name(solar_system_id) do + case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do + {:ok, %{solar_system_name: name}} -> present(name) + _ -> nil + end + rescue + _ -> nil + end + + defp present(nil), do: nil + defp present(""), do: nil + defp present(value) when is_binary(value), do: value +end diff --git a/lib/wanderer_app/external_events/discord/voice_gateway.ex b/lib/wanderer_app/external_events/discord/voice_gateway.ex new file mode 100644 index 000000000..068319017 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/voice_gateway.ex @@ -0,0 +1,85 @@ +defmodule WandererApp.ExternalEvents.Discord.VoiceGateway do + @moduledoc """ + Boot-time starter for the Nostrum gateway connection behind voice-mention + kill notifications. + + Not a running process: `start_link/1` attempts the start and returns + `:ignore`, so a failing gateway can never take the supervision tree with + it โ€” the invariant is that voice tagging degrades, kill delivery does + not. Nostrum's own application supervises the connection from then on + (reconnect/resume included). + + Startup outcomes are the one-time signals an operator needs: `info` on + success, `error` on failure, one `warning` for partial configuration. + After boot, per-lookup health is visible via the `mention_count` + telemetry measurement and `VoiceParticipants` debug logs. + """ + + require Logger + + alias WandererApp.Env + + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + # Nothing to restart: start_link always returns :ignore. + restart: :temporary + } + end + + def start_link(_opts) do + cond do + Env.discord_voice_mentions_enabled?() -> + start_gateway() + + partial_config?() -> + Logger.warning( + "[VoiceGateway] voice mentions disabled: set both DISCORD_BOT_TOKEN " <> + "and a valid positive-integer DISCORD_GUILD_ID" + ) + + true -> + :ok + end + + :ignore + end + + defp start_gateway do + # Seam: tests inject a starter fun via app env to exercise the fail-open + # contract without a live token; production starts Nostrum for real. + starter = + Application.get_env( + :wanderer_app, + :discord_gateway_starter, + &Application.ensure_all_started/1 + ) + + case starter.(:nostrum) do + {:ok, _apps} -> + Logger.info( + "[VoiceGateway] Discord gateway started; voice mentions enabled " <> + "for guild #{Env.discord_guild_id()}" + ) + + {:error, reason} -> + Logger.error( + "[VoiceGateway] Discord gateway failed to start: #{inspect(reason)} โ€” " <> + "kill notifications continue without voice mentions" + ) + end + end + + # Reached only when the feature predicate is false, so any raw value โ€” + # token without guild, guild without token, blank token, malformed guild + # id โ€” means the operator tried to configure this and something is missing + # or unusable. Raw values, not Env: Env normalizes blank/malformed to nil, + # which is exactly the difference between "unset" and "set but broken". + defp partial_config? do + external = Application.get_env(:wanderer_app, :external_events, []) + + Keyword.get(external, :discord_bot_token) != nil or + Keyword.get(external, :discord_guild_id) != nil + end +end diff --git a/lib/wanderer_app/external_events/discord/voice_participants.ex b/lib/wanderer_app/external_events/discord/voice_participants.ex new file mode 100644 index 000000000..2039d0b92 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/voice_participants.ex @@ -0,0 +1,159 @@ +defmodule WandererApp.ExternalEvents.Discord.VoiceParticipants do + @moduledoc """ + Mentions for everyone active in the configured guild's voice channels, + prepended to system-channel kill notifications. + + Voice states come from Nostrum's ETS `GuildCache`, populated passively by + the gateway connection `VoiceGateway` starts. The cache read is + microseconds and never touches the network, so it cannot slow dispatch. + + Every failure path โ€” feature unconfigured, gateway never started, guild + not cached yet โ€” returns `[]`: voice tagging must never cost a kill + notification. During a gateway reconnect the cache stays readable but + stale; pinging a recently-departed user is an accepted tradeoff (see the + design spec's error-handling section). + + Ported from wanderer-notifier's + `WandererNotifier.Infrastructure.Adapters.Discord.VoiceParticipants`. + """ + + require Logger + + # Discord channel types: 2 = GUILD_VOICE, 13 = GUILD_STAGE_VOICE + @voice_channel_types [2, 13] + + # Discord rejects `content` over 2,000 characters, and the worker treats a + # 400 as a permanent event failure feeding the auto-disable counter + # (`Worker`, `handle_post_result/2`). 1,800 leaves headroom for any + # existing content on the chunk. A partially-tagged ping beats a rejected + # notification. + @mention_budget 1_800 + + @doc """ + Mentions for the configured guild, `[]` unless the feature is configured + and the guild is cached. + """ + @spec get_active_voice_mentions() :: [String.t()] + def get_active_voice_mentions do + case WandererApp.Env.discord_guild_id() do + nil -> + [] + + guild_id -> + guild_id |> fetch_guild() |> mentions_from_guild() + end + rescue + error -> + Logger.debug(fn -> + "[VoiceParticipants] lookup failed: #{Exception.message(error)}" + end) + + [] + catch + kind, value -> + Logger.debug(fn -> + "[VoiceParticipants] lookup failed: #{inspect(kind)} #{inspect(value)}" + end) + + [] + end + + # Seam: tests inject a fixture-returning fun via app env; production falls + # through to Nostrum's cache. A config seam (not a parameter) keeps the + # dispatcher call site zero-arity. + defp fetch_guild(guild_id) do + fetcher = + Application.get_env( + :wanderer_app, + :discord_voice_guild_fetcher, + &Nostrum.Cache.GuildCache.get!/1 + ) + + fetcher.(guild_id) + end + + @doc """ + Pure core: mention list from a guild's voice states. Public so tests can + feed fixture guilds without Nostrum running. + """ + @spec mentions_from_guild(map()) :: [String.t()] + def mentions_from_guild(guild) do + afk_channel_id = Map.get(guild, :afk_channel_id) + voice_channel_ids = voice_channel_ids(guild, afk_channel_id) + voice_states = Map.get(guild, :voice_states) || [] + + mentions = + voice_states + |> Enum.filter(&(Map.get(&1, :channel_id) in voice_channel_ids)) + |> Enum.map(&"<@#{Map.get(&1, :user_id)}>") + |> Enum.uniq() + + if mentions == [] and voice_states != [] do + Logger.debug(fn -> + "[VoiceParticipants] #{length(voice_states)} voice state(s) present " <> + "but none in a taggable channel (afk_channel_id=#{inspect(afk_channel_id)})" + end) + end + + mentions + end + + # Voice/stage channels minus the AFK channel. Filtering states against + # this set covers both "in the AFK channel" and "in a non-voice channel". + defp voice_channel_ids(guild, afk_channel_id) do + (Map.get(guild, :channels) || %{}) + |> Map.values() + |> Enum.filter(&(Map.get(&1, :type) in @voice_channel_types)) + |> Enum.map(& &1.id) + |> Enum.reject(&(&1 == afk_channel_id)) + end + + @doc """ + Joins mentions into a content prefix within `budget` characters, appending + whole mentions until the next would overflow and silently dropping the + rest. Returns `{prefix_or_nil, included_count}`. + """ + @spec mention_prefix([String.t()], pos_integer()) :: + {String.t() | nil, non_neg_integer()} + def mention_prefix(mentions, budget \\ @mention_budget) + + def mention_prefix([], _budget), do: {nil, 0} + + def mention_prefix(mentions, budget) do + {included, _size} = + Enum.reduce_while(mentions, {[], 0}, fn mention, {acc, size} -> + separator = if acc == [], do: 0, else: 1 + addition = String.length(mention) + separator + + if size + addition > budget do + {:halt, {acc, size}} + else + {:cont, {[mention | acc], size + addition}} + end + end) + + case included do + [] -> {nil, 0} + list -> {list |> Enum.reverse() |> Enum.join(" "), length(list)} + end + end + + @doc """ + Prepends `prefix` to the first message's `"content"`; embeds and all other + chunks untouched. `nil` prefix is the no-op path โ€” no empty content key, + no stray whitespace. + """ + @spec prepend_to_messages([map()], String.t() | nil) :: [map()] + def prepend_to_messages(messages, nil), do: messages + def prepend_to_messages([], _prefix), do: [] + + def prepend_to_messages([first | rest], prefix) do + content = + case Map.get(first, "content") do + nil -> prefix + existing -> prefix <> " " <> existing + end + + [Map.put(first, "content", content) | rest] + end +end diff --git a/lib/wanderer_app/external_events/discord/worker.ex b/lib/wanderer_app/external_events/discord/worker.ex new file mode 100644 index 000000000..bb4fecd00 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/worker.ex @@ -0,0 +1,439 @@ +defmodule WandererApp.ExternalEvents.Discord.Worker do + @moduledoc """ + Serializes Discord delivery for one webhook. + + Discord rate-limits a webhook to roughly 5 requests/second and answers 429 + with a `retry-after`. Everything for a webhook funnels through this process so + concurrent kill batches cannot interleave and burst. + + A map has up to two destinations (system and character), and they get separate + workers on purpose: a 429 or a dead URL on one channel must not stall or + disable the other. + + ## Asynchronous: never blocks on the network + + Each request and each retry is scheduled with `Process.send_after/3` and + handled in `handle_info`, so the process returns to its mailbox between + attempts. This is deliberate: a worker that sleeps inside `handle_cast` + cannot process incoming casts, which would make the 100-item queue bound + meaningless โ€” events would pile up unbounded in the mailbox instead of being + dropped by the cap. (The existing `WebhookDispatcher` sleeps inside its retry + loop at `webhook_dispatcher.ex:355,376`; it is not the model here.) + + The HTTP request itself runs in a monitored `Task` for the same reason: a + synchronous call would block the process for up to the client's 15s receive + timeout, and a queue bound the mailbox routes around is not a bound at all. + + The database calls are the deliberate exception, and the guarantee is + specifically "never blocks on the *network*", not "never blocks". The + notification reload in `attempt/1` is synchronous because it must be ordered + before the send โ€” the whole point is that no request goes out against a stale + record, which an async reload could not guarantee. The status write shares + that call's result. Under Ecto pool exhaustion (prod `queue_target` 5s) these + can block, which is the same mailbox-accumulation failure mode via the DB + rather than the socket; it is bounded by the pool timeout and accepted here. + + ## Ids, not records + + The queue holds messages only; the webhook id lives in state. The webhook row + is reloaded from the database immediately before every send, so a URL the user + has replaced or deleted is never used, and a stale `consecutive_failures` + snapshot cannot corrupt the counter. + + If the reload finds the webhook deleted, or finds `enabled?` false, the queued + event is dropped silently: no request, and no status write. There is nothing + meaningful to record against a row the user removed, and writing a failure + onto a row they deliberately disabled would be misleading. + + ## Per-event status + + Delivery status is tracked per *event*, not per request: a multi-chunk event + is only a success once every chunk lands, and an early successful chunk never + clears an error recorded by a later one. + + ## Limits + + An event gets at most 5 attempts per chunk and is subject to a ~60s deadline. + The deadline is checked before dispatching an attempt, not while a request is + in flight, so an event can overrun by up to one request duration (worst case + ~75s with the client's 15s timeout). It is a bound on *starting* new work, not + a hard wall-clock cap; nothing downstream depends on the exact figure. + """ + + use GenServer, restart: :transient + + require Logger + + alias WandererApp.Api.MapDiscordWebhook + alias WandererApp.ExternalEvents.Discord.HttpClient + alias WandererApp.ExternalEvents.Discord.Mentions + + @idle_timeout :timer.seconds(60) + @max_queue 100 + @max_attempts 5 + @event_deadline_ms 60_000 + @max_retry_after_ms 10_000 + @min_retry_after_ms 50 + @default_retry_after_ms 1_000 + @backoff_base_ms 1_000 + @max_backoff_ms 8_000 + # Discord allows a webhook roughly 5 requests/second. A multi-chunk event + # posted back-to-back would burst straight into a 429 and burn attempts, so + # chunks are spaced just over that budget. + @inter_chunk_delay_ms 250 + + def start_link(opts) do + webhook_id = Keyword.fetch!(opts, :webhook_id) + registry = Keyword.fetch!(opts, :registry) + GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {registry, webhook_id}}) + end + + @doc """ + Queues one event's messages for delivery. + + No id argument: the worker IS the webhook now, so the queue holds messages + alone and the id comes from state. + """ + def enqueue(pid, messages) do + GenServer.cast(pid, {:enqueue, messages}) + end + + @impl true + def init(opts) do + # Both timeouts are overridable so tests can exercise idle shutdown and + # deadline expiry without waiting a real minute. Production always uses the + # module defaults. + idle_timeout = Keyword.get(opts, :idle_timeout, @idle_timeout) + + {:ok, + %{ + webhook_id: Keyword.fetch!(opts, :webhook_id), + idle_timeout: idle_timeout, + event_deadline_ms: Keyword.get(opts, :event_deadline_ms, @event_deadline_ms), + queue: :queue.new(), + queue_len: 0, + # nil when idle, otherwise the event currently being delivered + current: nil + }, idle_timeout} + end + + @impl true + def handle_cast({:enqueue, messages}, state) do + state = + state + |> push(messages) + |> maybe_start_next() + + {:noreply, state, state.idle_timeout} + end + + @impl true + def handle_info(:attempt, %{current: nil} = state) do + state = maybe_start_next(state) + {:noreply, state, state.idle_timeout} + end + + def handle_info(:attempt, state) do + state = attempt(state) + {:noreply, state, state.idle_timeout} + end + + # Reply from the in-flight request task. + def handle_info({ref, result}, %{current: %{task_ref: ref}} = state) when is_reference(ref) do + # Demonitor first: the task is about to send its :DOWN, and we do not want + # to treat normal completion as a crash. + Process.demonitor(ref, [:flush]) + state = handle_post_result(state, result) + {:noreply, state, state.idle_timeout} + end + + # The request task crashed. Treat it as a transient failure and retry, rather + # than losing the event: async_nolink means this does not take the worker down. + def handle_info({:DOWN, ref, :process, _pid, reason}, %{current: %{task_ref: ref}} = state) + when is_reference(ref) do + state = put_current(state, %{state.current | task_ref: nil}) + + state = + schedule_retry( + state, + backoff_ms(state.current.attempt), + "request crashed: #{inspect(reason)}" + ) + + {:noreply, state, state.idle_timeout} + end + + # A late reply or DOWN from a task we already gave up on โ€” ignore it. + def handle_info({ref, _result}, state) when is_reference(ref) do + Process.demonitor(ref, [:flush]) + {:noreply, state, state.idle_timeout} + end + + def handle_info({:DOWN, ref, :process, _pid, _reason}, state) when is_reference(ref) do + {:noreply, state, state.idle_timeout} + end + + def handle_info(:timeout, %{queue_len: 0, current: nil} = state) do + {:stop, :normal, state} + end + + def handle_info(:timeout, state), do: {:noreply, state, state.idle_timeout} + + def handle_info(msg, state) do + Logger.debug("[Discord.Worker] unexpected message: #{inspect(msg)}") + {:noreply, state, state.idle_timeout} + end + + # -- queue ---------------------------------------------------------------- + + defp push(%{queue_len: len} = state, item) when len >= @max_queue do + # Drop the oldest: a feed 100 messages behind has already failed its + # purpose, and unbounded growth risks the VM. queue_len is unchanged + # because one item leaves as one enters. + {{:value, _dropped}, q} = :queue.out(state.queue) + + Logger.warning( + "[Discord.Worker] queue full for webhook #{state.webhook_id}, dropping oldest event" + ) + + %{state | queue: :queue.in(item, q)} + end + + defp push(state, item) do + %{state | queue: :queue.in(item, state.queue), queue_len: state.queue_len + 1} + end + + defp maybe_start_next(%{current: current} = state) when not is_nil(current), do: state + + defp maybe_start_next(state) do + case :queue.out(state.queue) do + {:empty, _} -> + state + + {{:value, messages}, rest} -> + current = %{ + pending: messages, + attempt: 1, + task_ref: nil, + # Most recently loaded record, reused for the status write so + # finishing an event does not re-query what we just read. + webhook: nil, + deadline: System.monotonic_time(:millisecond) + state.event_deadline_ms + } + + send(self(), :attempt) + %{state | queue: rest, queue_len: state.queue_len - 1, current: current} + end + end + + # -- one attempt ---------------------------------------------------------- + + defp attempt(%{current: %{pending: []}} = state), do: finish(state, :ok) + + defp attempt(%{current: current} = state) do + cond do + System.monotonic_time(:millisecond) > current.deadline -> + finish(state, {:error, "delivery deadline exceeded", :count}) + + current.attempt > @max_attempts -> + finish(state, {:error, "gave up after #{@max_attempts} attempts", :count}) + + true -> + # Reload every time: the URL may have been replaced or the webhook + # deleted since this event was queued. This reload is the one that + # matters โ€” nothing is sent against a stale record. + case MapDiscordWebhook.by_id(state.webhook_id) do + {:ok, webhook} -> + state = put_current(state, %{current | webhook: webhook}) + + if webhook.enabled? do + do_post(state, webhook) + else + # Disabled while queued โ€” drop the event silently, no status write. + drop_current(state) + end + + _ -> + Logger.debug("[Discord.Worker] webhook gone, dropping queued event") + drop_current(state) + end + end + end + + # Runs the request in a monitored Task so the worker never blocks on the + # socket. The HTTP client can take up to its 15s receive timeout; blocking + # here would let casts pile up in the mailbox and silently defeat the + # bounded state queue โ€” the same defect as sleeping, just harder to see. + defp do_post(%{current: current} = state, webhook) do + [message | _rest] = current.pending + message = attach_allowed_mentions(message) + url = webhook.webhook_url + + task = + Task.Supervisor.async_nolink( + WandererApp.ExternalEvents.Discord.TaskSupervisor, + fn -> HttpClient.post(url, message) end + ) + + put_current(state, %{current | task_ref: task.ref}) + end + + # Every message that carries `"content"` must also carry `allowed_mentions`, + # or Discord defaults to parsing @everyone/@here/user/role mentions found in + # the text โ€” see the design doc's "Mention injection is a real risk". A + # caller that already set one (Task 6's route alerts, with real configured + # targets) is left untouched; this only fills the gap for callers that + # never think about mentions at all (the static test message, the overflow + # string, voice-mention prefixes). + defp attach_allowed_mentions(message) do + if Map.has_key?(message, "content") and not Map.has_key?(message, "allowed_mentions") do + Map.put(message, "allowed_mentions", Mentions.allowed_mentions([])) + else + message + end + end + + defp handle_post_result(state, result) do + current = state.current + [_sent | rest] = current.pending + + case result do + {:ok, status, _headers} when status in 200..299 -> + # Chunk delivered: move on with a fresh attempt budget, same deadline. + state = put_current(state, %{current | pending: rest, attempt: 1, task_ref: nil}) + + if rest == [] do + finish(state, :ok) + else + # Spaced, not immediate: back-to-back chunks would trip the webhook's + # own rate limit and turn a successful event into a run of 429s. + Process.send_after(self(), :attempt, @inter_chunk_delay_ms) + state + end + + {:ok, 429, headers} -> + state = put_current(state, %{current | task_ref: nil}) + schedule_retry(state, retry_after_ms(headers), "Discord returned 429 (rate limited)") + + {:ok, 404, _headers} -> + # The only status that disables immediately: the webhook was deleted + # upstream and will never recover. + finish(state, {:error, "Discord returned 404 โ€” webhook was deleted", :disable}) + + {:ok, status, _headers} when status in 400..499 -> + # 401/403 and any other 4xx are permanent for this event but do NOT + # disable on their own โ€” they feed the 10-consecutive-failure + # threshold, so a single transient 403 cannot kill a map's feed. + finish(state, {:error, "Discord returned #{status}", :count}) + + {:ok, status, _headers} -> + state = put_current(state, %{current | task_ref: nil}) + schedule_retry(state, backoff_ms(current.attempt), "Discord returned #{status}") + + {:error, reason} -> + state = put_current(state, %{current | task_ref: nil}) + schedule_retry(state, backoff_ms(current.attempt), "request failed: #{inspect(reason)}") + end + end + + # Schedules the next attempt instead of sleeping, so the mailbox keeps moving. + defp schedule_retry(%{current: current} = state, delay_ms, reason) do + next_attempt = current.attempt + 1 + + if next_attempt > @max_attempts do + finish(state, {:error, reason, :count}) + else + Process.send_after(self(), :attempt, delay_ms) + put_current(state, %{current | attempt: next_attempt}) + end + end + + defp put_current(state, current), do: %{state | current: current} + + defp drop_current(state) do + state |> put_current(nil) |> maybe_start_next() + end + + # -- event completion ----------------------------------------------------- + + # Reuses the record `attempt/1` loaded moments ago rather than re-querying. + # The reload that matters for correctness is the one *before the send*; this + # is only the status write, and `record_failure` re-reads the counter inside + # the resource action anyway, so a slightly stale copy here cannot corrupt it. + # Falls back to a query when no record was loaded (e.g. the deadline expired + # before the first attempt). + defp finish(%{current: current} = state, outcome) do + case current.webhook do + nil -> record_outcome(state.webhook_id, outcome) + webhook -> apply_outcome(webhook, outcome) + end + + drop_current(state) + end + + defp record_outcome(webhook_id, outcome) do + case MapDiscordWebhook.by_id(webhook_id) do + {:ok, webhook} -> apply_outcome(webhook, outcome) + _ -> :ok + end + end + + defp apply_outcome(webhook, :ok) do + case MapDiscordWebhook.record_success(webhook) do + {:ok, _} -> :ok + {:error, reason} -> Logger.warning("[Discord] record_success failed: #{inspect(reason)}") + end + end + + defp apply_outcome(webhook, {:error, reason, :disable}) do + case MapDiscordWebhook.disable(webhook, to_string(reason)) do + {:ok, _} -> :ok + {:error, err} -> Logger.warning("[Discord] disable failed: #{inspect(err)}") + end + end + + defp apply_outcome(webhook, {:error, reason, :count}) do + # record_failure disables at @max_consecutive_failures on the resource side. + case MapDiscordWebhook.record_failure(webhook, to_string(reason)) do + {:ok, _} -> :ok + {:error, err} -> Logger.warning("[Discord] record_failure failed: #{inspect(err)}") + end + end + + # -- timing helpers ------------------------------------------------------- + + defp retry_after_ms(headers) do + headers + |> Enum.find_value(fn {k, v} -> + if String.downcase(k) == "retry-after", do: v + end) + |> parse_retry_after() + end + + defp parse_retry_after(nil), do: @default_retry_after_ms + + defp parse_retry_after(value) do + # Clamped to @max_retry_after_ms. Tradeoff, stated explicitly: if Discord + # asks for a wait longer than 10s we retry sooner than requested and burn + # an attempt, so a heavily rate-limited event can exhaust its 5 attempts + # and be recorded as a failure rather than waiting the full window. We + # accept that to keep the worker's queue moving โ€” a 60s honored wait would + # stall every other event for this map behind one rate-limited chunk, and + # the event deadline would likely kill it anyway. + case Float.parse(to_string(value)) do + {seconds, _} -> + seconds + |> Kernel.*(1000) + |> round() + |> min(@max_retry_after_ms) + |> max(@min_retry_after_ms) + + :error -> + @default_retry_after_ms + end + end + + defp backoff_ms(attempt) do + (@backoff_base_ms * :math.pow(2, attempt - 1)) |> min(@max_backoff_ms) |> round() + end +end diff --git a/lib/wanderer_app/external_events/discord/worker_supervisor.ex b/lib/wanderer_app/external_events/discord/worker_supervisor.ex new file mode 100644 index 000000000..ffb84e390 --- /dev/null +++ b/lib/wanderer_app/external_events/discord/worker_supervisor.ex @@ -0,0 +1,145 @@ +defmodule WandererApp.ExternalEvents.Discord.WorkerSupervisor do + @moduledoc """ + Starts one delivery worker per Discord webhook on demand, addressed through a + Registry keyed by webhook id. + + Per webhook, not per map: Discord's rate limits are per webhook, and a failure + must be attributable to the destination that caused it. Sharing a worker + between a map's system and character channels would let a 429 on one stall the + other, and a 404 on one disable both. + + Workers are transient: they own an in-memory queue, shut down when idle, and + are not restarted with their queue intact. Losing a queued notification on + crash is acceptable; duplicating a delivered one is not. + """ + + use Supervisor + + require Logger + + alias WandererApp.ExternalEvents.Discord.Worker + + @registry WandererApp.ExternalEvents.Discord.Registry + @dyn_sup WandererApp.ExternalEvents.Discord.DynamicSupervisor + # Bounded so a worker wedged on a slow DB call cannot block the destroy that + # is stopping it. The exit is caught either way; the worker is brought down. + @stop_timeout_ms 5_000 + + def start_link(opts \\ []), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + children = [ + {Registry, keys: :unique, name: @registry}, + {Task.Supervisor, name: WandererApp.ExternalEvents.Discord.TaskSupervisor}, + {DynamicSupervisor, name: @dyn_sup, strategy: :one_for_one} + ] + + # :rest_for_one, not :one_for_one โ€” workers register in the Registry, so a + # Registry crash would leave them running but unreachable, and the next + # deliver/2 would start a *second* worker for the same webhook and + # double-post. Restarting everything after the Registry clears those orphans. + Supervisor.init(children, strategy: :rest_for_one) + end + + @doc """ + Enqueues messages for one webhook, starting its worker if it is not running. + + Takes the webhook *id*, never the record: the worker reloads it just before + each send so a replaced or deleted webhook is not used, and so a stale + `consecutive_failures` snapshot cannot corrupt the counter. + + Returns `{:error, :not_running}` when the worker infrastructure is not + started (e.g. webhooks globally disabled), mirroring `stop_worker/1`'s + tolerance of the same condition. Callers on the dispatch path must not crash + just because the kill-switch is off. + """ + def deliver(_webhook_id, []), do: :ok + + def deliver(webhook_id, messages) do + case ensure_worker(webhook_id) do + {:ok, pid} -> + Worker.enqueue(pid, messages) + + {:error, :not_running} -> + # Not an error worth logging on every event: the kill-switch being off + # is a normal configuration, not a failure. + {:error, :not_running} + + {:error, reason} -> + Logger.warning( + "[Discord] could not start worker for webhook #{webhook_id}: #{inspect(reason)}" + ) + + {:error, reason} + end + end + + @doc """ + Stops one webhook's delivery worker if one is running, discarding its queue. + + Called from the webhook resource's destroy, and from the parent notification's + destroy for each of its children: without it, a removed webhook keeps + receiving whatever was already queued. A no-op when the worker infrastructure + is not running at all (e.g. webhooks globally disabled, or in tests that do + not start this supervisor). + """ + def stop_worker(webhook_id) do + case Process.whereis(@registry) do + nil -> + :ok + + _ -> + case Registry.lookup(@registry, webhook_id) do + # The worker may have idled out or crashed between the lookup and the + # stop; either way the post-condition (no worker running) holds. + [{pid, _}] -> try_stop(pid) + [] -> :ok + end + + :ok + end + end + + defp try_stop(pid) do + GenServer.stop(pid, :normal, @stop_timeout_ms) + catch + # Already gone, or did not terminate within the timeout โ€” in the latter case + # GenServer.stop/3 has already killed it. Either way there is no worker left. + :exit, _ -> :ok + end + + defp ensure_worker(webhook_id) do + # Guard exactly as stop_worker/1 does: Registry.lookup on an unregistered + # name raises ArgumentError, which would crash the dispatcher whenever + # webhooks are globally disabled and this supervisor was never started. + case Process.whereis(@registry) do + nil -> {:error, :not_running} + _ -> lookup_or_start(webhook_id) + end + end + + defp lookup_or_start(webhook_id) do + case Registry.lookup(@registry, webhook_id) do + # Registry releases a dead owner's key asynchronously, so a lookup can + # still return a pid that has just exited (idle shutdown or stop_worker). + [{pid, _}] when is_pid(pid) -> + if Process.alive?(pid), do: {:ok, pid}, else: start_worker(webhook_id) + + [] -> + start_worker(webhook_id) + end + end + + defp start_worker(webhook_id) do + spec = {Worker, webhook_id: webhook_id, registry: @registry} + + case DynamicSupervisor.start_child(@dyn_sup, spec) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + error -> error + end + end + + def registry, do: @registry +end diff --git a/lib/wanderer_app/external_events/discord_dispatcher.ex b/lib/wanderer_app/external_events/discord_dispatcher.ex new file mode 100644 index 000000000..d8732acf1 --- /dev/null +++ b/lib/wanderer_app/external_events/discord_dispatcher.ex @@ -0,0 +1,1193 @@ +defmodule WandererApp.ExternalEvents.DiscordDispatcher do + @moduledoc """ + Delivers `:map_kill` events to a map's configured Discord webhook. + + A sibling of `WebhookDispatcher`, not a variant of it: Discord ignores HMAC + signatures and the `X-Wanderer-*` headers, requires its own body shape, and + enforces its own rate limits. + + ## Why a GenServer + + `dispatch_event/2` is a cast, matching `WebhookDispatcher` + (`webhook_dispatcher.ex:16,42-43`). `MapEventRelay` calls it inline, and the + work here โ€” config lookup, system-class resolution, formatting โ€” involves + cache misses that hit the database. Doing that on the relay's process would + delay SSE and generic webhook delivery for every other subscriber. + + Responsibilities here are filtering and deduplication; serialized HTTP + delivery belongs to the per-map worker. + + ## Deduplication is at-most-once, by choice + + The dedup key is `"\#{map_id}:\#{killmail_id}"` and is deliberately NOT scoped by + webhook role. A kill posts once per map, to one destination โ€” routing chooses + which. Scoping the key by role would double-post any kill eligible for both. + + Killmails are marked as *attempted* before delivery is confirmed, so an event + lost to a delivery failure is never re-sent. This is deliberate. Marking only + after success would require holding the batch across an async worker + round-trip and would still race on a crash between send and mark. Of the two + failure modes โ€” post a kill twice, or silently drop one โ€” a duplicate post in + a chat channel is irreversible and worse; a dropped kill is still visible in + the kills widget and on zKillboard. + + This is not a delivery guarantee. It is an explicit decision to lose the + occasional kill rather than ever double-post one. + + The one exception is `{:error, :not_running}` from the worker supervisor, + which means nothing was enqueued at all: those marks are released, since no + request can possibly have gone out and therefore no duplicate is possible. + + The rationale covers losses to *delivery failure* only. Kills past the + formatter's per-destination cap are never rendered into a message, so they are + not marked at all and stay eligible if they arrive again. The same holds for + kills the router drops: they belong to no partition and are never marked. + """ + + use GenServer + + require Logger + + alias WandererApp.Api.{MapDiscordNotification, MapDiscordWebhook} + alias WandererApp.Env + + alias WandererApp.ExternalEvents.Discord.{ + CorpTickers, + EmbedFormatter, + Matcher, + NotableItems, + Router, + SystemName, + VoiceParticipants + } + + alias WandererApp.ExternalEvents.Discord.WorkerSupervisor + + @cache :discord_notification_cache + @dedup_cache :discord_dedup_cache + # Comfortably longer than any plausible upstream replay window, matching the + # 24h TTLs already used for kill caches. + @dedup_ttl :timer.hours(24) + + # Lives in the dedup cache rather than the dispatcher's own state ON PURPOSE: + # the window exists because the dedup MARKS are gone, and those marks belong + # to that cache. The two are separate children of a `:one_for_one` supervisor, + # so a dedup-cache-only crash loses every mark while the dispatcher keeps + # running -- exactly the case a dispatcher-lifecycle window would miss. + # + # Cannot collide with a dedup key: those are `"#{map_id}:#{killmail_id}"` + # with a UUID map_id, and this contains no colon. + @startup_sentinel "discord-startup-grace-until" + + # Enrichment failure cooldown, shared by both enrichment steps. The counter + # lives in the shared api cache and carries the cooldown as its TTL, so it both + # counts and expires. Threshold and window are deliberate guesses: telemetry on + # `[:wanderer_app, :discord, :notable_items]` and + # `[:wanderer_app, :discord, :corp_tickers]` is what will tune them. + @enrichment_cache :api_cache + @notable_items_failure_key "discord-notable-items-failures" + @enrichment_failure_threshold 3 + @enrichment_cooldown_ms :timer.seconds(60) + + # Corporation-ticker enrichment shares that bookkeeping, under its own key so + # an ESI outage that stops ticker lookups does not also suppress notable items + # (or the reverse). + @corp_tickers_failure_key "discord-corp-tickers-failures" + + def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts), do: {:ok, %{}} + + @doc """ + Entry point called by `MapEventRelay` for every external event. + + A cast: the relay must never block on Discord-side work. + """ + @spec dispatch_event(map_id :: String.t(), struct()) :: :ok + def dispatch_event(map_id, event) do + GenServer.cast(__MODULE__, {:dispatch_event, map_id, event}) + end + + @doc """ + Posts a fixed sample message to ONE webhook so a user can confirm it works. + Routed through the same worker so it cannot jump the queue. + + Takes a webhook id, not a map id: a map can have two destinations and the + user tests them separately. + + Reports *configuration* errors synchronously, each as its own atom, because + they ask the user for different things: + + * `:notifications_disabled` โ€” the global kill-switch is off. Only an + administrator can change it. + * `:webhook_not_found` โ€” no row with that id, in practice a stale page whose + destination was deleted in another session. + * `:webhook_url_missing` โ€” the row exists but carries no usable URL. + * `:webhook_disabled` โ€” the row exists and has a URL; `enabled?` is false. + Nothing is wrong with the configuration, it is switched off. + + These were one `:not_configured` until the Task 16 gate. Telling a user to + save a webhook URL when they had already saved one and merely unticked the + box is why they were split; `map_notifications_component.ex` had to + re-derive the disabled case from its own assigns to avoid printing that. + + Delivery success is **not** awaited. The final hop is `Worker.enqueue/2`, a + cast, so `:ok` means "accepted for delivery", not "Discord accepted it" โ€” a + dead or revoked webhook URL still returns `:ok` here and surfaces later as a + failure recorded on the webhook record (`last_error`, `consecutive_failures`). + UI built on this must not promise the user that the message arrived. + """ + @spec send_test_message(webhook_id :: String.t()) :: + :ok + | {:error, + :notifications_disabled + | :webhook_not_found + | :webhook_url_missing + | :webhook_disabled + | term()} + def send_test_message(webhook_id) do + # Checked here rather than inside the worker: when the gate is off the + # worker supervisor and its Registry are not running at all, so calling + # into them would crash the caller (the LiveView). + if enabled_globally?() do + with {:ok, webhook} <- resolve_test_webhook(webhook_id) do + message = %{ + "content" => "Wanderer test message โ€” Discord kill notifications are configured." + } + + case WorkerSupervisor.deliver(webhook.id, [message]) do + :ok -> + :ok + + # The gate read as on, but the worker tree is not up (e.g. the app + # was started with webhooks disabled and the config flipped since). + # Report it rather than claiming the test message was sent. + {:error, :not_running} -> + {:error, :notifications_disabled} + + {:error, reason} -> + {:error, reason} + end + end + else + {:error, :notifications_disabled} + end + end + + # Clause order is the user-facing precedence and is load-bearing: a row that + # is BOTH disabled and URL-less reports `:webhook_disabled`. That preserves + # what the component rendered before this split, where its local `enabled?` + # check ran ahead of any call into the dispatcher. + defp resolve_test_webhook(webhook_id) do + case MapDiscordWebhook.by_id(webhook_id) do + {:ok, %{enabled?: false}} -> + {:error, :webhook_disabled} + + {:ok, %{webhook_url: url} = webhook} when is_binary(url) and url != "" -> + {:ok, webhook} + + {:ok, _webhook} -> + # `webhook_url` is `allow_nil? false` and validated on write, so this is + # not reachable through the UI. It is reachable through a hand-repaired + # row or a vault key that no longer decrypts the stored ciphertext โ€” + # exactly the cases where "save a URL first" is the right advice and + # "no such destination" would send the operator looking in the wrong + # place. Deliberately NOT `valid_webhook_url?/1`: re-running the + # stricter write-time validator here would reclassify rows that were + # accepted when they were saved. + {:error, :webhook_url_missing} + + _ -> + {:error, :webhook_not_found} + end + end + + @doc """ + Drops the cached config for a map after its record changes. + + A plain function, not a GenServer call: it is invoked from Ash + after_transaction hooks that may run before this process exists. + """ + def invalidate_cache(map_id) do + Cachex.del(@cache, map_id) + :ok + rescue + # The cache is not started in every context (e.g. unit tests); a missing + # cache must not fail the write that triggered the invalidation. + _ -> :ok + end + + @impl true + def handle_cast({:dispatch_event, map_id, event}, state) do + do_dispatch(map_id, event) + {:noreply, state} + end + + @impl true + def handle_info(msg, state) do + Logger.debug(fn -> "[Discord] dispatcher received unexpected message: #{inspect(msg)}" end) + {:noreply, state} + end + + defp do_dispatch(map_id, %{type: :map_kill, payload: payload}) do + now = DateTime.utc_now() + + with true <- enabled_globally?(), + {:ok, notification} <- fetch_config(map_id), + true <- notification.enabled?, + {:ok, system_id, killmails} <- extract_kills(payload), + # Before the age filter and dedup on purpose: a kill dropped here was + # never marked attempted, so it stays eligible if the same batch + # arrives again once the map cache says the system is present. + true <- system_on_map_counted?(map_id, system_id, length(killmails)), + # Resolved here, ONCE per batch, and deliberately below every + # enablement gate: the sentinel read, the `Env` reads and their + # validation warnings must not be paid by maps that will never post to + # Discord. See `age_limits/0`. + {:ok, max_killmail_age_seconds, drop_context} <- age_limits(), + # Stale kills (an upstream replay burst on reconnect) are filtered + # BEFORE dedup: a kill dropped here for age was never marked attempted, + # so it stays eligible if it arrives again inside the freshness window. + # It is also upstream of every partition, so no partition can mark a + # stale kill. + [_ | _] = recent <- + filter_fresh(map_id, killmails, now, max_killmail_age_seconds, drop_context), + [_ | _] = fresh <- reject_duplicates_counted(map_id, recent) do + # System-level filtering used to happen here for the whole batch. It now + # lives in `Router.route/3`, because `excluded_systems` and `wh_only` have + # per-kill carve-outs for kills involving this map's own pilots. + # Both enrichment steps block this singleton, and they run in sequence, + # so the worst-case hold on a batch is the SUM of their budgets โ€” 3s with + # both at the default 1500ms, not 1500ms. That is accepted rather than + # shared: notable items is opt-in, so the default ceiling stays at one + # budget, and a deployment that opts in has already accepted paying for + # ESI killmail fetches on this path. Adding a third enrichment here, or + # turning notable items on by default, is the point at which the two + # should be bounded by one shared deadline instead. + fresh + |> partition(map_id, notification) + |> enrich_notable_items() + |> enrich_corp_tickers() + |> Enum.each(fn {webhook, entries} -> + deliver_partition(map_id, system_id, webhook, entries) + end) + + :ok + else + _ -> :ok + end + end + + # No DB or HTTP work of its own: fetch_config/1 reads the already-cached + # notification (a cache miss costs one Ecto query, same as the kill path + # pays on any cache-cold map), and RouteWatcherSupervisor.notify/1 is a cast + # into a different process. This dispatcher is a SINGLETON shared by every + # map's kill batches โ€” the solve, the embed, and the HTTP post all belong to + # Discord.RouteWatcher, one GenServer per map, never to this clause. + # Removals are in this list for the same reason additions are: the transition + # table only runs during an evaluation, and an evaluation only happens on a + # notify. Without a notify on removal the state stays `{:qualifying, N}` + # indefinitely, so a route that closes and later re-opens at the same or a + # worse jump count takes the silent `jumps < old` branch and is never + # announced. Solver load stays bounded by the watcher's debounce and the + # 15-minute route cache, which is what bounds it for the additions too. + defp do_dispatch(map_id, %{type: type}) + when type in [ + :add_system, + :connection_added, + :connection_updated, + :connection_removed, + :deleted_system + ] do + with true <- enabled_globally?(), + {:ok, notification} <- fetch_config(map_id), + true <- notification.route_alerts_enabled?, + home_system_id when not is_nil(home_system_id) <- notification.home_system_id do + route_watcher_supervisor().notify(map_id) + end + + :ok + end + + defp do_dispatch(_map_id, _event), do: :ok + + defp route_watcher_supervisor, + do: + Application.get_env( + :wanderer_app, + :route_watcher_supervisor, + WandererApp.ExternalEvents.Discord.RouteWatcherSupervisor + ) + + # Routing is per kill, so a single `:map_kill` batch can now contain kills + # bound for different destinations, or for none. Kills that drop belong to no + # partition and are NEVER MARKED, so they stay eligible if they arrive again. + # + # Each entry keeps its verdict alongside the kill: the formatter needs it for + # colouring and title (loss vs kill). + defp partition(kills, map_id, notification) do + tracked = Matcher.tracked_eve_ids(map_id) + focus = notification.focus_corp_ids || [] + + kills + |> Enum.reduce(%{}, fn kill, acc -> + verdict = Matcher.involvement(kill, tracked, focus) + + case Router.route(kill, notification, verdict) do + {:ok, webhook} -> Map.update(acc, webhook, [{kill, verdict}], &[{kill, verdict} | &1]) + :drop -> acc + end + end) + # Reduce prepends; restore the batch's original order per destination. + |> Map.new(fn {webhook, entries} -> {webhook, Enum.reverse(entries)} end) + end + + # -- notable items --------------------------------------------------------- + # + # Enrichment happens HERE โ€” after routing and after the per-destination cap โ€” + # for two reasons. Enriching before `partition/3` spends ESI and market calls + # on kills the router then drops. Enriching a whole partition spends the + # budget on kills past `max_kills_per_event/0`, which are never rendered, and + # starves the ones that are. + # + # It runs inline, so it BLOCKS THE SINGLETON DISPATCHER for up to + # `Env.notable_items_timeout_ms/0`. That budget is load-bearing: every map's + # kill batches funnel through this one process. Do not remove or raise it + # without revisiting the decision โ€” an unbounded enrichment here stalls kill + # notifications instance-wide. + # + # The budget bounds ONE batch, not the mailbox. During a sustained ESI or + # market outage every batch would pay it in full and the dispatcher would fall + # arbitrarily far behind, so the failure cooldown below is part of the feature + # rather than a follow-up: after @enrichment_failure_threshold consecutive + # failures, enrichment is skipped outright for the cooldown window. + defp enrich_notable_items(partitions) do + cond do + not Env.notable_items_enabled?() -> + emit_notable_items(0, 0, 0, :disabled) + partitions + + cooldown_active?(@notable_items_failure_key) -> + emit_notable_items(0, 0, 0, :skipped_cooldown) + partitions + + true -> + run_enrichment(partitions, render_candidates(partitions)) + end + end + + defp run_enrichment(partitions, []) do + emit_notable_items(0, 0, 0, :ok) + partitions + end + + defp run_enrichment(partitions, candidates) do + started = System.monotonic_time() + + case start_enrichment(candidates) do + nil -> + # No task supervisor: nothing was attempted, so this is not a failure to + # count against the cooldown. Distinct from `:timeout` so the telemetry + # does not read as a slow enricher when it is a missing supervisor. + emit_notable_items(0, length(candidates), 0, :unavailable) + partitions + + task -> + # The documented `yield || shutdown` idiom. `Task.yield/2` returns + # `{:exit, reason}` INLINE when the task crashes, so all three outcomes + # are handled here and none of them reaches `handle_info/2`. + result = + Task.yield(task, Env.notable_items_timeout_ms()) || + Task.shutdown(task, :brutal_kill) + + handle_enrichment(partitions, candidates, result, System.monotonic_time() - started) + end + end + + defp handle_enrichment(partitions, candidates, result, duration) do + case result do + {:ok, by_kill} when is_map(by_kill) -> + reset_failures(@notable_items_failure_key) + emit_notable_items(duration, length(candidates), item_count(by_kill), :ok) + merge_notable_items(partitions, by_kill) + + {:ok, _unexpected} -> + reset_failures(@notable_items_failure_key) + emit_notable_items(duration, length(candidates), 0, :ok) + partitions + + {:exit, reason} -> + note_notable_items_failure("enricher crashed: #{inspect(reason)}") + emit_notable_items(duration, length(candidates), 0, :crash) + partitions + + _ -> + note_notable_items_failure("enricher timed out") + emit_notable_items(duration, length(candidates), 0, :timeout) + partitions + end + end + + # `async_nolink`, never `Task.async`: a linked task would take this singleton + # down with it on an enrichment exception โ€” the exact opposite of fail-open. + # `Discord.TaskSupervisor` is started by `WorkerSupervisor`, which only runs + # when webhooks are globally enabled; `do_dispatch/2` gates on that before + # reaching here, so this is safe. Do not move enrichment above that gate. + defp start_enrichment(candidates) do + enricher = NotableItems.impl() + + start_task(fn -> enricher.enrich(candidates) end) + end + + # Returns nil when the task could not be started at all, which in practice + # means the Discord task supervisor is not running. Logged rather than + # swallowed: it silently disables *both* enrichments, and unlike the other + # failure paths it is a supervision-tree problem, not an ESI one. + defp start_task(fun) do + Task.Supervisor.async_nolink(WandererApp.ExternalEvents.Discord.TaskSupervisor, fun) + rescue + # The supervisor is not up. Fail open like every other enrichment failure. + error -> + Logger.warning("[Discord] enrichment task not started: #{Exception.message(error)}") + nil + catch + :exit, reason -> + Logger.warning("[Discord] enrichment task not started, exited: #{inspect(reason)}") + nil + end + + # Only the kills that will actually be rendered, deduplicated. `Router.route/3` + # returns exactly one destination per kill so a kill cannot currently appear in + # two partitions; the dedup is cheap insurance against that property changing. + defp render_candidates(partitions) do + partitions + |> Enum.flat_map(fn {_webhook, entries} -> + entries + |> Enum.take(EmbedFormatter.max_kills_per_event()) + |> Enum.map(fn {kill, _verdict} -> kill end) + end) + |> Enum.uniq_by(& &1["killmail_id"]) + end + + defp merge_notable_items(partitions, by_kill) when map_size(by_kill) == 0, do: partitions + + defp merge_notable_items(partitions, by_kill) do + Map.new(partitions, fn {webhook, entries} -> + entries = + Enum.map(entries, fn {kill, verdict} -> + case Map.get(by_kill, kill["killmail_id"]) do + nil -> {kill, verdict} + items -> {Map.put(kill, "notable_items", items), verdict} + end + end) + + {webhook, entries} + end) + end + + defp item_count(by_kill), + do: by_kill |> Map.values() |> Enum.map(&length/1) |> Enum.sum() + + defp cooldown_active?(key) do + case Cachex.get(@enrichment_cache, key) do + {:ok, count} when is_integer(count) -> count >= @enrichment_failure_threshold + _ -> false + end + end + + # The counter carries the cooldown TTL, so reaching the threshold suppresses + # enrichment until the entry expires and a fresh attempt is made. + defp note_notable_items_failure(reason) do + note_failure(@notable_items_failure_key, fn count -> + "[Discord] notable items #{reason} (#{count} consecutive); " <> + "batch delivered without the section" + end) + end + + defp note_failure(key, message_fun) do + count = + case Cachex.get(@enrichment_cache, key) do + {:ok, n} when is_integer(n) -> n + 1 + _ -> 1 + end + + Cachex.put(@enrichment_cache, key, count, ttl: @enrichment_cooldown_ms) + + Logger.warning(message_fun.(count)) + rescue + # The cache is not started in every context; a bookkeeping failure must not + # cost the batch. + _ -> :ok + end + + defp reset_failures(key) do + Cachex.del(@enrichment_cache, key) + rescue + _ -> :ok + end + + defp emit_notable_items(duration, kill_count, item_count, outcome) do + :telemetry.execute( + [:wanderer_app, :discord, :notable_items], + %{duration: duration, kill_count: kill_count, item_count: item_count}, + %{outcome: outcome} + ) + end + + # -- corporation tickers --------------------------------------------------- + # + # Same placement, budget and fail-open rules as notable items above: after + # routing and the per-destination cap, blocking for at most + # `Env.corp_tickers_timeout_ms/0`, and every failure path returns the + # partitions untouched so the batch still posts. + # + # Unlike notable items this is NOT opt-in. The embed already claims to show + # corporations; a payload that arrives without a ticker silently deletes the + # whole parenthetical, which reads as "this pilot has no corp" rather than as + # a missing optional section. See `Discord.CorpTickers` for why the payload + # cannot be trusted here. + defp enrich_corp_tickers(partitions) do + cond do + not Env.corp_tickers_enabled?() -> + emit_corp_tickers(0, 0, 0, :disabled) + partitions + + cooldown_active?(@corp_tickers_failure_key) -> + emit_corp_tickers(0, 0, 0, :skipped_cooldown) + partitions + + true -> + # `render_candidates/1` deliberately runs once per enrichment rather + # than once per batch. Hoisting it would mean handing the second + # enricher a list built before the first one ran, which is correct only + # while no enricher reads another's output โ€” an unwritten invariant + # worth more than the microseconds a second pass over ten maps costs. + run_corp_tickers(partitions, render_candidates(partitions)) + end + end + + defp run_corp_tickers(partitions, []) do + emit_corp_tickers(0, 0, 0, :ok) + partitions + end + + defp run_corp_tickers(partitions, candidates) do + # Counted here rather than inside the task so the telemetry still reports + # how much work was owed when the task times out or crashes. + wanted = length(CorpTickers.missing_corp_ids(candidates)) + + if wanted == 0 do + emit_corp_tickers(0, 0, 0, :ok) + partitions + else + started = System.monotonic_time() + enricher = CorpTickers.impl() + + case start_task(fn -> enricher.enrich(candidates) end) do + nil -> + emit_corp_tickers(0, wanted, 0, :unavailable) + partitions + + task -> + result = + Task.yield(task, Env.corp_tickers_timeout_ms()) || + Task.shutdown(task, :brutal_kill) + + handle_corp_tickers(partitions, wanted, result, System.monotonic_time() - started) + end + end + end + + defp handle_corp_tickers(partitions, wanted, result, duration) do + case result do + # Work was owed and none of it came back. Unlike notable items โ€” where an + # empty result legitimately means "this kill dropped nothing notable" โ€” + # every id here was asked for because a ticker is missing, so resolving + # none of them means ESI answered nothing. Counting it is what lets the + # cooldown stop us paying the full round trip on every subsequent batch, + # and what makes an ESI outage visible instead of silently reappearing as + # the exact bug this enrichment exists to fix. + {:ok, tickers} when is_map(tickers) and map_size(tickers) == 0 -> + note_corp_tickers_failure("resolved none of #{wanted} corporations") + emit_corp_tickers(duration, wanted, 0, :unresolved) + partitions + + {:ok, tickers} when is_map(tickers) -> + reset_failures(@corp_tickers_failure_key) + log_partial_corp_tickers(wanted, map_size(tickers)) + emit_corp_tickers(duration, wanted, map_size(tickers), :ok) + merge_corp_tickers(partitions, tickers) + + # Only reachable through a misconfigured `:corp_tickers_enricher`. Treated + # as a failure rather than an empty result so it cannot masquerade as a + # healthy batch forever. + {:ok, unexpected} -> + note_corp_tickers_failure("enricher returned #{inspect(unexpected)}, expected a map") + emit_corp_tickers(duration, wanted, 0, :invalid) + partitions + + {:exit, reason} -> + note_corp_tickers_failure("enricher crashed: #{inspect(reason)}") + emit_corp_tickers(duration, wanted, 0, :crash) + partitions + + _ -> + note_corp_tickers_failure("enricher timed out") + emit_corp_tickers(duration, wanted, 0, :timeout) + partitions + end + end + + # A batch that resolves *some* of what it asked for is a success โ€” the kills it + # did resolve render correctly โ€” so it neither trips the cooldown nor counts as + # a failure. It is still worth a line: sustained partial resolution means an + # ESI problem that no other signal here reports, since the outcome stays `:ok`. + # + # Only when the *majority* went unresolved, though. A corporation ESI does not + # know, or whose record carries no ticker, never resolves and is re-requested + # by every batch containing that kill, since nothing is ever written back to + # the payload. Warning on any shortfall would turn one such corporation into a + # permanent warning stream describing a fixed data condition rather than the + # degradation this is meant to catch. + defp log_partial_corp_tickers(wanted, resolved) when resolved * 2 < wanted do + Logger.warning( + "[Discord] corp tickers resolved #{resolved} of #{wanted} corporations; " <> + "the rest post without their ticker" + ) + end + + defp log_partial_corp_tickers(_wanted, _resolved), do: :ok + + defp merge_corp_tickers(partitions, tickers) when map_size(tickers) == 0, do: partitions + + defp merge_corp_tickers(partitions, tickers) do + Map.new(partitions, fn {webhook, entries} -> + {webhook, + Enum.map(entries, fn {kill, verdict} -> + {CorpTickers.apply_tickers(kill, tickers), verdict} + end)} + end) + end + + defp note_corp_tickers_failure(reason) do + note_failure(@corp_tickers_failure_key, fn count -> + "[Discord] corp tickers #{reason} (#{count} consecutive); " <> + "batch delivered without corporation tickers" + end) + end + + defp emit_corp_tickers(duration, corp_count, resolved_count, outcome) do + :telemetry.execute( + [:wanderer_app, :discord, :corp_tickers], + %{duration: duration, corp_count: corp_count, resolved_count: resolved_count}, + %{outcome: outcome} + ) + end + + # The role reaching `SystemName.display_name/3` is a LITERAL atom matched out + # of the destination itself, one clause per role โ€” never `webhook.role` + # threaded through as a variable. That resolver is the privacy boundary + # (map-local chain names on the `:system` webhook only, because the character + # channel is commonly public), and it can only enforce that boundary if the + # role it receives is genuinely this destination's own. A variable reused + # across partitions is the one remaining leak path, and a Discord post cannot + # be recalled. An unknown role raises here instead of guessing โ€” correct. + defp deliver_partition(map_id, system_id, %{role: :system} = webhook, entries) do + deliver_to( + map_id, + webhook, + :system, + SystemName.display_name(map_id, system_id, :system), + entries + ) + end + + defp deliver_partition(map_id, system_id, %{role: :character} = webhook, entries) do + deliver_to( + map_id, + webhook, + :character, + SystemName.display_name(map_id, system_id, :character), + entries + ) + end + + # Each non-empty partition is formatted and delivered independently. + defp deliver_to(map_id, webhook, role, system_name, entries) do + # THE CAP IS PER DESTINATION, NOT PER EVENT. It is a Discord message-size + # concern, so two destinations do not compete for one 30-kill budget. + rendered = Enum.take(entries, EmbedFormatter.max_kills_per_event()) + marked = Enum.map(rendered, fn {kill, _verdict} -> kill end) + + # Marked before delivery: see the moduledoc โ€” this is at-most-once by + # choice, not an oversight. Only the kills the formatter will actually + # render are marked; kills past the cap are never turned into a message, so + # marking them would burn them for the full dedup TTL without ever sending + # them. + mark_attempted(map_id, marked) + + {prefix, mention_count} = voice_mention_prefix(role) + + # PASS THE WHOLE PARTITION, NOT `rendered`. The formatter applies the cap + # itself and counts the remainder into its "โ€ฆand N more kills not shown." + # line. Handing it the pre-truncated list compiles, passes most tests, and + # silently deletes the overflow line. + entries + |> EmbedFormatter.format_batch(system_name) + |> VoiceParticipants.prepend_to_messages(prefix) + |> then(&WorkerSupervisor.deliver(webhook.id, &1)) + |> handle_delivery_result(map_id, role, marked, mention_count) + end + + # Voice mentions go to the system channel only (spec decision), and only + # when configured. `nil` count means "feature off" and keeps the + # measurement out of telemetry entirely, so 0 always means "enabled but + # nobody taggable" โ€” the distinction operators need. + # + # `discord_mentions_enabled?/0` is the instance-wide incident switch and + # gates this path too, not just route alerts: .env.example documents it as + # silencing "role and user pings on kill and route notifications". It is + # checked HERE rather than folded into `discord_voice_mentions_enabled?/0`, + # which also decides whether `VoiceGateway` connects at boot โ€” flipping the + # switch must silence pings immediately, not require a redeploy to undo. + defp voice_mention_prefix(:system) do + if Env.discord_mentions_enabled?() and Env.discord_voice_mentions_enabled?() do + VoiceParticipants.get_active_voice_mentions() + |> VoiceParticipants.mention_prefix() + else + {nil, nil} + end + end + + defp voice_mention_prefix(_role), do: {nil, nil} + + defp handle_delivery_result(:ok, map_id, role, kills, mention_count) do + measurements = + case mention_count do + nil -> %{count: length(kills)} + n -> %{count: length(kills), mention_count: n} + end + + :telemetry.execute( + [:wanderer_app, :discord_dispatcher, :dispatched], + measurements, + %{map_id: map_id, role: role} + ) + end + + # Nothing was enqueued, so no duplicate is possible: release this partition's + # dedup marks so a later event carrying these kills can still be delivered + # once the worker tree is up. Other partitions in the same event are + # unaffected โ€” their marks stand or fall on their own delivery result. Not + # logged at warning level โ€” the kill-switch being off is a normal + # configuration, not a failure. + defp handle_delivery_result({:error, :not_running}, map_id, role, kills, _mention_count) do + unmark(map_id, kills) + + Logger.debug(fn -> + "[Discord] worker infrastructure not running; dropped #{length(kills)} " <> + "#{role} kills for map #{map_id}" + end) + + emit_not_delivered(map_id, role, kills, :not_running) + end + + defp handle_delivery_result({:error, reason}, map_id, role, kills, _mention_count) do + Logger.warning( + "[Discord] #{role} delivery enqueue failed for map #{map_id}: #{inspect(reason)}" + ) + + emit_not_delivered(map_id, role, kills, reason) + end + + defp emit_not_delivered(map_id, role, kills, reason) do + :telemetry.execute( + [:wanderer_app, :discord_dispatcher, :not_delivered], + %{count: length(kills)}, + %{map_id: map_id, role: role, reason: reason} + ) + end + + # Bounds the `SystemMapIndex` staleness window: the index refreshes on a + # 5-minute timer when the kills client is disconnected, so a system removed + # from a map keeps producing kill broadcasts until the next refresh. The live + # map cache is dropped by `WandererApp.Map.remove_system/2` immediately, so it + # is strictly fresher. + # + # FAIL-OPEN, and this is the whole reason the guard is safe to add: it returns + # false ONLY on a positive "this map is readable and does not have that + # system". A map with no live GenServer has no `:map_cache` entry, which is + # not evidence of removal. `systems` is keyed by `solar_system_id` + # (`WandererApp.Map` defstruct, and `add_system/2`). + # + # The started flag is checked FIRST because a present entry is not yet a + # readable one: `WandererApp.Map.new/1` commits the struct with `systems: %{}` + # and `add_systems!/2` fills it one `update_map/2` at a time, so during map + # start the entry positively reports "not on this map" for every system. Same + # precondition `can_broadcast?/1` uses (`map_server_impl.ex:463-467`); it is + # `insert`ed `false` at `start_map/1` entry, `true` only at its end, and + # deleted on stop, so all three non-ready states fall through to `true`. + defp system_on_map?(map_id, system_id) do + with true <- WandererApp.Cache.lookup!("map_#{map_id}:started", false), + {:ok, %{systems: systems}} when is_map(systems) <- WandererApp.Map.get_map(map_id) do + Map.has_key?(systems, system_id) + else + _ -> true + end + rescue + # `Cachex.get/2` RAISES against an unstarted cache rather than returning an + # error tuple, and `get_map/1` has no catch-all clause, so a non-`{:ok, _}` + # return raises CaseClauseError. Either would crash the dispatcher and lose + # the whole batch โ€” the opposite of failing open. Same contract + # `Matcher.tracked_eve_ids/1` rescues (`discord/matcher.ex:53-60`). + _ -> true + end + + # Batch-level, so it costs nothing per kill. Without it the membership guard + # is the one drop path with no trace at all, and an operator asking "why did + # this kill not post?" gets `:age`, `:startup_age`, `:duplicate`, and silence. + defp system_on_map_counted?(map_id, system_id, count) do + if system_on_map?(map_id, system_id) do + true + else + emit_dropped(map_id, count, :not_on_map) + false + end + end + + defp enabled_globally?, do: WandererApp.Env.webhooks_enabled?() + + defp fetch_config(map_id) do + case Cachex.get(@cache, map_id) do + {:ok, nil} -> + load_and_cache(map_id) + + {:ok, :none} -> + {:error, :not_configured} + + {:ok, notification} -> + {:ok, notification} + + _ -> + load_and_cache(map_id) + end + end + + defp load_and_cache(map_id) do + # `:webhooks` is loaded here, once, and rides along in the cached struct: the + # dispatch path must not query the destination table per killmail. Every + # child create/update/destroy invalidates this entry (Task 1), so a newly + # added or removed webhook is picked up immediately rather than after the + # 5-minute TTL. + with {:ok, notification} when not is_nil(notification) <- + MapDiscordNotification.by_map(map_id), + {:ok, notification} <- Ash.load(notification, :webhooks) do + Cachex.put(@cache, map_id, notification) + {:ok, notification} + else + _ -> + # Cache the negative result too, so busy unconfigured maps do not + # hit the database on every killmail. + Cachex.put(@cache, map_id, :none) + {:error, :not_configured} + end + end + + # Only killmail batches are interesting; `:kill_count` updates carry no + # killmails and would be noise in a channel. + defp extract_kills(%{"type" => :killmail_update} = payload) do + case payload["killmails"] do + [_ | _] = kills -> {:ok, payload["solar_system_id"], kills} + _ -> :skip + end + end + + defp extract_kills(_), do: :skip + + # The `seen` accumulator matters as much as the cache lookup: `mark_attempted/2` + # only runs after the whole batch is filtered, so a batch carrying the same + # killmail_id twice passed both copies through and posted the kill twice. + defp reject_duplicates(map_id, killmails) do + {kept, _seen} = + Enum.reduce(killmails, {[], MapSet.new()}, fn kill, {kept, seen} -> + key = dedup_key(map_id, kill) + + cached? = + case Cachex.exists?(@dedup_cache, key) do + {:ok, true} -> true + _ -> false + end + + if cached? or MapSet.member?(seen, key) do + {kept, seen} + else + {[kill | kept], MapSet.put(seen, key)} + end + end) + + Enum.reverse(kept) + end + + # Resolves everything the age filter needs, ONCE per batch, never per kill: + # `kill_fresh?/3` runs once per killmail, and re-reading (and re-validating) + # config on every one of potentially dozens of kills would turn a single + # misconfigured deployment into a warning-per-kill log flood. The `{:ok, ...}` + # shape exists only so this can be a `with` clause in `do_dispatch/2` โ€” an + # `if` cannot be one in either its block or its keyword form, and hoisting it + # back out of the chain would make every map on the instance pay for it before + # the enablement gates rejected the batch. + # + # The result, and the explicit third argument to `kill_fresh?/3`, must survive + # any rewrite of that chain โ€” dropping either silently reopens the flood. + # Filtering for age happens ONCE, before partitioning: moving it inside the + # per-destination loop reintroduces it too. + # + # The `arm_startup_grace/0` read is one ETS read per batch, deliberately NOT + # cached in dispatcher state: the dedup cache and this GenServer are + # independent children of a `:one_for_one` supervisor, so a cache-only crash + # would leave cached state stale in exactly the scenario the window exists for. + defp age_limits do + startup_arm_until = arm_startup_grace() + + ordinary_max_age_seconds = Env.discord_max_killmail_age_seconds() + + # The branch picks WHICH accessor to call. It does not move the call + # per-kill. + max_killmail_age_seconds = + if within_startup_grace?(startup_arm_until) do + Env.discord_startup_max_killmail_age_seconds() + else + ordinary_max_age_seconds + end + + {:ok, max_killmail_age_seconds, + {ordinary_max_age_seconds, startup_grace_remaining_ms(startup_arm_until)}} + end + + # Wraps the age filter purely so the drop is counted. A kill dropped for age + # otherwise falls out of the `with` chain into its catch-all `:ok` and leaves + # no trace whatsoever, which makes "did we suppress it, or did we never + # receive it?" unanswerable during an incident. + defp filter_fresh(map_id, killmails, now, max_age_seconds, drop_context) do + {ordinary_max_age_seconds, remaining_ms} = drop_context + + {kept, dropped} = Enum.split_with(killmails, &kill_fresh?(&1, now, max_age_seconds)) + + # Classified PER KILL against the ORDINARY limit, not by whether the window + # is open. Inside the window a kill can fail the tighter limit for either + # of two reasons: the window suppressed it, or it was old enough that the + # pre-existing hour limit would have dropped it anyway. Labelling both + # `:startup_age` inflates "kills the window suppressed" with kills the + # window had nothing to do with -- in exactly the metric an operator reads + # to judge whether the window is too aggressive. + # + # Outside the window `max_age_seconds == ordinary_max_age_seconds`, so + # every dropped kill fails this second test too and classifies as `:age`. + # No branch on `startup?` is needed to get that: it falls out. + {startup_age, age} = + Enum.split_with(dropped, &kill_fresh?(&1, now, ordinary_max_age_seconds)) + + emit_dropped(map_id, length(startup_age), :startup_age) + emit_dropped(map_id, length(age), :age) + + # At info, not debug: this fires at most once per batch, only when the + # window actually suppressed something, and it is the line an operator + # searches for when a restart looks too quiet. Ordinary age and dedup drops + # stay telemetry-only -- they are steady-state behaviour, not an event. + if startup_age != [] do + Logger.info(fn -> + "[Discord] startup grace window suppressed #{length(startup_age)} " <> + "replayed killmail(s); #{div(remaining_ms, 1000)}s of the window remain" + end) + end + + kept + end + + defp reject_duplicates_counted(map_id, killmails) do + kept = reject_duplicates(map_id, killmails) + + emit_dropped(map_id, length(killmails) - length(kept), :duplicate) + + kept + end + + # Silent when nothing was dropped: a counter that fires with `count: 0` on + # every healthy batch buries the signal it exists to carry. + defp emit_dropped(_map_id, 0, _reason), do: :ok + + defp emit_dropped(map_id, count, reason) do + :telemetry.execute( + [:wanderer_app, :discord_dispatcher, :killmail_dropped], + %{count: count}, + %{map_id: map_id, reason: reason} + ) + + :ok + end + + # Records that we have *attempted* this killmail, not that Discord accepted + # it. Named accordingly so the at-most-once semantics are not misread. + defp mark_attempted(map_id, killmails) do + Enum.each(killmails, fn kill -> + Cachex.put(@dedup_cache, dedup_key(map_id, kill), true, ttl: @dedup_ttl) + end) + end + + defp unmark(map_id, killmails) do + Enum.each(killmails, fn kill -> Cachex.del(@dedup_cache, dedup_key(map_id, kill)) end) + end + + @doc """ + Dedup cache key for one killmail, by kill map or by bare killmail id. + + Public so tests derive the key instead of hardcoding its format: a hardcoded + key makes `refute`-style assertions ("this kill was never marked") pass + vacuously the moment the format changes. + + NOT role-scoped, deliberately: exactly one destination is chosen per kill, + so one key per (map, killmail) is exactly right. Should a future change ever + post one kill to BOTH channels, this key must gain the role โ€” otherwise the + first post marks the kill and the second is silently suppressed. + """ + @spec dedup_key(String.t(), map() | integer() | String.t()) :: String.t() + def dedup_key(map_id, kill) when is_map(kill), do: dedup_key(map_id, kill["killmail_id"]) + def dedup_key(map_id, killmail_id), do: "#{map_id}:#{killmail_id}" + + @doc "Name of the dedup cache, so tests do not hardcode it either." + @spec dedup_cache() :: atom() + def dedup_cache, do: @dedup_cache + + @doc "Name of the startup-window sentinel key, so tests do not hardcode it." + @spec startup_sentinel_key() :: String.t() + def startup_sentinel_key, do: @startup_sentinel + + @doc """ + Reads the startup-window deadline from the dedup cache, writing one if it is + absent. Idempotent, and called once per kill batch. + + ABSENT means the cache is new and its marks are gone, so arm. PRESENT means + the cache survived, so honour the stored deadline as it is -- including an + expired one. The deadline is absolute, not a TTL, precisely so that an expired + window is still a *present* sentinel and nothing re-arms it while the cache + lives. + + Called per batch rather than from `init/1` because the cache and the + dispatcher are independent children of a `:one_for_one` supervisor. A + dedup-cache-only crash never runs `init/1`, so a deadline cached in dispatcher + state would go stale in exactly the scenario this window exists for. + + Public because it is the unit under test: driving it directly is the only way + to exercise a cache-only restart, which restarting the dispatcher cannot + reach. + """ + @spec arm_startup_grace() :: integer() | :never + def arm_startup_grace do + case Cachex.get(@dedup_cache, @startup_sentinel) do + {:ok, nil} -> + # The only `Env` read on this path, and it runs once per cache + # lifetime, not once per batch -- so the once-per-batch config + # constraint holds. + arm_until = + System.monotonic_time(:millisecond) + Env.discord_startup_grace_seconds() * 1000 + + Cachex.put(@dedup_cache, @startup_sentinel, arm_until) + # Belt-and-braces, and deliberately kept. `:discord_dedup_cache` has no + # default expiration today (see the cache list in `application.ex` โ€” + # the `default_ttl:` it was declared with was a Cachex 2.x option that + # 3.x silently ignored), so nothing here expires on its own. If a + # default expiration is ever turned on for this cache, an expiring + # sentinel would re-arm the startup window after a day of uptime on a + # healthy cache that never lost a mark. `Cachex.Actions.Put.execute/4` + # honours only an INTEGER `:ttl`, so a bare put would inherit that + # default; this pin makes the sentinel independent of it either way. + Cachex.persist(@dedup_cache, @startup_sentinel) + + arm_until + + {:ok, arm_until} when is_integer(arm_until) -> + arm_until + + _ -> + :never + end + rescue + # `Cachex.get/2` raises against an unstarted cache. Failing to read the + # sentinel must leave the window CLOSED, not open: an unreadable cache is + # not evidence that marks were lost, and arming on it would suppress real + # killmails. Fail-open here means "do not suppress". + _ -> :never + end + + @doc """ + Whether a batch is inside the startup grace window. + + `:never` rather than a sentinel integer for the unarmed case: Erlang + monotonic time may be negative, so no integer is reliably "in the past". + """ + @spec within_startup_grace?(integer() | :never) :: boolean() + def within_startup_grace?(:never), do: false + + def within_startup_grace?(arm_until) when is_integer(arm_until), + do: System.monotonic_time(:millisecond) < arm_until + + # Clamped at zero: a batch can land microseconds after the deadline while + # `startup?` was computed just before it, and a negative "seconds remaining" + # in a log line reads as a bug in the window rather than a rounding artifact. + defp startup_grace_remaining_ms(:never), do: 0 + + defp startup_grace_remaining_ms(arm_until) when is_integer(arm_until), + do: max(arm_until - System.monotonic_time(:millisecond), 0) + + @doc """ + Whether a killmail is recent enough to post. + + Guards against an upstream replay burst on reconnect dumping hours of history + into a chat channel, and is the precondition that would make a join-time + preload safe if one is ever added. + + **Fail-open on purpose.** An absent, non-string, or unparseable `kill_time` + allows the kill through, matching the dispatcher's posture everywhere else: a + malformed field must not silently suppress notifications. Do not change this + to fail-closed โ€” a parse regression would then look exactly like a quiet map. + + `now` is an argument rather than an internal `DateTime.utc_now/0` call so the + boundary cases are testable without sleeping or freezing the clock. + + `max_age_seconds` is likewise an argument, not read from `Env` here: this + runs once per killmail, and `do_dispatch/2` resolves it ONCE per batch โ€” via + `age_limits/0`, below its enablement gates โ€” and passes it down explicitly. + Reading `Env.discord_max_killmail_age_seconds/0` + per kill would mean a misconfigured value (see `Env`'s own validation) + re-logs its warning once per kill instead of once per batch. The default + here exists only so this function stays directly callable with two + arguments in tests; `do_dispatch/2` always supplies the third explicitly. + + During the startup grace window the caller passes + `Env.discord_startup_max_killmail_age_seconds/0` instead. This function is + unchanged by that: it already takes the maximum as an explicit argument, so + both call paths flow through the same comparison. + """ + @spec kill_fresh?(map(), DateTime.t(), pos_integer()) :: boolean() + def kill_fresh?( + kill, + now \\ DateTime.utc_now(), + max_age_seconds \\ Env.discord_max_killmail_age_seconds() + ) + + def kill_fresh?(%{"kill_time" => kill_time}, now, max_age_seconds) when is_binary(kill_time) do + case DateTime.from_iso8601(kill_time) do + {:ok, killed_at, _utc_offset} -> + # Positive when the kill is in the past. A future-dated kill_time gives a + # negative age and passes, which is the intent: this guard is about + # staleness only. + DateTime.diff(now, killed_at, :second) <= max_age_seconds + + {:error, _reason} -> + true + end + end + + def kill_fresh?(_kill, _now, _max_age_seconds), do: true +end diff --git a/lib/wanderer_app/external_events/json_api_formatter.ex b/lib/wanderer_app/external_events/json_api_formatter.ex index 43e9f4f43..dabc7695f 100644 --- a/lib/wanderer_app/external_events/json_api_formatter.ex +++ b/lib/wanderer_app/external_events/json_api_formatter.ex @@ -8,6 +8,23 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do alias WandererApp.ExternalEvents.Event + # Explicit allowlist for character payloads. Character events broadcast a + # WandererApp.Api.Character struct, which also carries the OAuth token fields + # and the owner hash - those must never be read. + # + # :id is deliberately absent: it is the resource identity, and JSON:API + # forbids an attribute named "id". + @character_attribute_keys [ + :eve_id, + :name, + :corporation_id, + :corporation_ticker, + :alliance_id, + :ship_name, + :solar_system_id, + :online + ] + @doc """ Formats an event into JSON:API structure. @@ -40,387 +57,412 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do end # Event-specific resource data formatting + # + # Producer: map_server_systems_impl.ex:673, :729 and :943. The :943 variant + # omits :name, so that attribute is legitimately nil there. defp format_resource_data(%Event{type: :add_system, payload: payload} = event) do + {type, id} = system_identity(event, payload) + %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id], + "type" => type, + "id" => id, "attributes" => %{ - "solar_system_id" => payload["solar_system_id"] || payload[:solar_system_id], - "name" => payload["name"] || payload[:name], - "locked" => payload["locked"] || payload[:locked], - "x" => payload["x"] || payload[:x], - "y" => payload["y"] || payload[:y], + "solar_system_id" => fetch(payload, :solar_system_id), + "name" => fetch(payload, :name), + "position_x" => fetch(payload, :position_x), + "position_y" => fetch(payload, :position_y), "created_at" => event.timestamp }, - "relationships" => %{ - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_systems_impl.ex:385. name/position_x/position_y are + # deliberately sent as nil and are omitted here rather than echoed as nulls. defp format_resource_data(%Event{type: :deleted_system, payload: payload} = event) do + {type, id} = system_identity(event, payload) + %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id], + "type" => type, + "id" => id, + "attributes" => %{ + "solar_system_id" => fetch(payload, :solar_system_id) + }, "meta" => %{ "deleted" => true, "deleted_at" => event.timestamp }, - "relationships" => %{ - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # No producer for :system_renamed exists in lib/. This clause bounds the + # output shape; its attribute names are unverified against a real payload. defp format_resource_data(%Event{type: :system_renamed, payload: payload} = event) do + {type, id} = system_identity(event, payload) + %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id], + "type" => type, + "id" => id, "attributes" => %{ - "name" => payload["name"] || payload[:name], + "solar_system_id" => fetch(payload, :solar_system_id), + "name" => fetch(payload, :name), "updated_at" => event.timestamp }, - "relationships" => %{ - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_systems_impl.ex:1187 defp format_resource_data(%Event{type: :system_metadata_changed, payload: payload} = event) do + {type, id} = system_identity(event, payload) + %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id], + "type" => type, + "id" => id, "attributes" => %{ - "locked" => payload["locked"] || payload[:locked], - "position_x" => payload["position_x"] || payload[:position_x], - "position_y" => payload["position_y"] || payload[:position_y], + "solar_system_id" => fetch(payload, :solar_system_id), + "name" => fetch(payload, :name), + "temporary_name" => fetch(payload, :temporary_name), + "labels" => fetch(payload, :labels), + "description" => fetch(payload, :description), + "status" => fetch(payload, :status), + "locked" => fetch(payload, :locked), + "position_x" => fetch(payload, :position_x), + "position_y" => fetch(payload, :position_y), "updated_at" => event.timestamp }, - "relationships" => %{ - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_signatures_impl.ex:148 - the only :signature_added site. + # + # The producer sends sig.eve_id - the in-game signature code, not the record + # UUID. api/map_system_signature.ex is uuid_primary_key with eve_id unique + # only as identity :uniq_system_eve_id, [:system_id, :eve_id], so the code + # neither resolves as a map_system_signatures id nor is globally unique. + # Identity is therefore the event ULID and the code is an attribute. defp format_resource_data(%Event{type: :signature_added, payload: payload} = event) do %{ - "type" => "map_system_signatures", - "id" => payload["signature_id"] || payload[:signature_id], + "type" => "signature_events", + "id" => event.id, "attributes" => %{ - "signature_id" => payload["signature_identifier"] || payload[:signature_identifier], - "signature_type" => payload["signature_type"] || payload[:signature_type], - "name" => payload["name"] || payload[:name], + "solar_system_id" => fetch(payload, :solar_system_id), + "signature_id" => fetch(payload, :signature_id), + "name" => fetch(payload, :name), + "kind" => fetch(payload, :kind), + "group" => fetch(payload, :group), + # The producer key is :type; renamed on the wire because JSON:API + # forbids an attribute named "type". + "signature_type" => fetch(payload, :type), "created_at" => event.timestamp }, - "relationships" => %{ - "system" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_signatures_impl.ex:159 and :245. Sends only + # solar_system_id and signature_id. defp format_resource_data(%Event{type: :signature_removed, payload: payload} = event) do %{ - "type" => "map_system_signatures", - "id" => payload["signature_id"] || payload[:signature_id], + "type" => "signature_events", + "id" => event.id, + "attributes" => %{ + "solar_system_id" => fetch(payload, :solar_system_id), + "signature_id" => fetch(payload, :signature_id) + }, "meta" => %{ "deleted" => true, "deleted_at" => event.timestamp }, - "relationships" => %{ - "system" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_signatures_impl.ex:166 and :250. A summary event that + # names no single signature, so the event ULID is the identity. + defp format_resource_data(%Event{type: :signatures_updated, payload: payload} = event) do + %{ + "type" => "signature_updates", + "id" => event.id, + "attributes" => %{ + "solar_system_id" => fetch(payload, :solar_system_id), + "added_count" => fetch(payload, :added_count), + "updated_count" => fetch(payload, :updated_count), + "removed_count" => fetch(payload, :removed_count), + "updated_at" => event.timestamp + }, + "relationships" => %{"map" => map_relationship(event)} + } + end + + # Producer: map_server_connections_impl.ex:779. Endpoints are EVE solar + # system ids, so they are attributes: no map_systems UUID is available. defp format_resource_data(%Event{type: :connection_added, payload: payload} = event) do %{ "type" => "map_connections", - "id" => payload["connection_id"] || payload[:connection_id], + "id" => rid(fetch(payload, :connection_id)), "attributes" => %{ - "type" => payload["type"] || payload[:type], - "time_status" => payload["time_status"] || payload[:time_status], - "mass_status" => payload["mass_status"] || payload[:mass_status], - "ship_size_type" => payload["ship_size_type"] || payload[:ship_size_type], + "solar_system_source_id" => fetch(payload, :solar_system_source_id), + "solar_system_target_id" => fetch(payload, :solar_system_target_id), + # The producer key is :type; renamed on the wire because JSON:API + # forbids an attribute named "type". + "connection_type" => fetch(payload, :type), + "ship_size_type" => fetch(payload, :ship_size_type), + "mass_status" => fetch(payload, :mass_status), + "time_status" => fetch(payload, :time_status), "created_at" => event.timestamp }, - "relationships" => %{ - "solar_system_source" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["solar_system_source"] || payload[:solar_system_source] - } - }, - "solar_system_target" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["solar_system_target"] || payload[:solar_system_target] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_connections_impl.ex:1104 defp format_resource_data(%Event{type: :connection_removed, payload: payload} = event) do %{ "type" => "map_connections", - "id" => payload["connection_id"] || payload[:connection_id], + "id" => rid(fetch(payload, :connection_id)), + "attributes" => %{ + "solar_system_source_id" => fetch(payload, :solar_system_source_id), + "solar_system_target_id" => fetch(payload, :solar_system_target_id) + }, "meta" => %{ "deleted" => true, "deleted_at" => event.timestamp }, - "relationships" => %{ - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_connections_impl.ex:1161 defp format_resource_data(%Event{type: :connection_updated, payload: payload} = event) do %{ "type" => "map_connections", - "id" => payload["connection_id"] || payload[:connection_id], + "id" => rid(fetch(payload, :connection_id)), "attributes" => %{ - "type" => payload["type"] || payload[:type], - "time_status" => payload["time_status"] || payload[:time_status], - "mass_status" => payload["mass_status"] || payload[:mass_status], - "ship_size_type" => payload["ship_size_type"] || payload[:ship_size_type], - "locked" => payload["locked"] || payload[:locked], + "solar_system_source_id" => fetch(payload, :solar_system_source_id), + "solar_system_target_id" => fetch(payload, :solar_system_target_id), + # Renamed from the producer's :type - JSON:API reserves "type". + "connection_type" => fetch(payload, :type), + "ship_size_type" => fetch(payload, :ship_size_type), + "mass_status" => fetch(payload, :mass_status), + "time_status" => fetch(payload, :time_status), + "locked" => fetch(payload, :locked), + "custom_info" => fetch(payload, :custom_info), "updated_at" => event.timestamp }, - "relationships" => %{ - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_characters_impl.ex:1037 and :1048. The payload is a + # WandererApp.Api.Character struct - fields are projected through + # @character_attribute_keys so tokens can never reach the wire. defp format_resource_data(%Event{type: :character_added, payload: payload} = event) do %{ "type" => "characters", - "id" => payload["character_id"] || payload[:character_id], - "attributes" => %{ - "eve_id" => payload["eve_id"] || payload[:eve_id], - "name" => payload["name"] || payload[:name], - "corporation_name" => payload["corporation_name"] || payload[:corporation_name], - "corporation_ticker" => payload["corporation_ticker"] || payload[:corporation_ticker], - "added_at" => event.timestamp - }, - "relationships" => %{ - "system" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "id" => rid(fetch(payload, :id)), + "attributes" => Map.put(character_attrs(payload), "added_at", event.timestamp), + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_characters_impl.ex:301. Also an Api.Character struct. defp format_resource_data(%Event{type: :character_removed, payload: payload} = event) do %{ "type" => "characters", - "id" => payload["character_id"] || payload[:character_id], + "id" => rid(fetch(payload, :id)), + "attributes" => character_attrs(payload), "meta" => %{ - "removed_from_system" => true, + "removed" => true, "removed_at" => event.timestamp }, - "relationships" => %{ - "system" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} } end + # No producer for :character_updated exists in lib/. Field-enumerated anyway + # so that a future producer cannot leak a raw struct through this clause. defp format_resource_data(%Event{type: :character_updated, payload: payload} = event) do %{ "type" => "characters", - "id" => payload["character_id"] || payload[:character_id], - "attributes" => %{ - "ship_type_id" => payload["ship_type_id"] || payload[:ship_type_id], - "ship_name" => payload["ship_name"] || payload[:ship_name], - "updated_at" => event.timestamp - }, - "relationships" => %{ - "system" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "id" => rid(fetch(payload, :id)), + "attributes" => Map.put(character_attrs(payload), "updated_at", event.timestamp), + "relationships" => %{"map" => map_relationship(event)} } end + # Producer: map_server_characters_impl.ex:486. Sends %{characters: [...]}, + # a list of Api.Character structs, so `data` is an array. + defp format_resource_data(%Event{type: :characters_updated, payload: payload} = event) do + payload + |> fetch(:characters) + |> List.wrap() + |> Enum.map(fn character -> + %{ + "type" => "characters", + "id" => rid(fetch(character, :id)), + "attributes" => Map.put(character_attrs(character), "updated_at", event.timestamp), + "relationships" => %{"map" => map_relationship(event)} + } + end) + end + + # Producer: acl_event_broadcaster.ex:52. member_id is a real + # access_list_members UUID and acl_id a real access_lists UUID. defp format_resource_data(%Event{type: :acl_member_added, payload: payload} = event) do %{ "type" => "access_list_members", - "id" => payload["member_id"] || payload[:member_id], + "id" => rid(fetch(payload, :member_id)), "attributes" => %{ - "character_eve_id" => payload["character_eve_id"] || payload[:character_eve_id], - "character_name" => payload["character_name"] || payload[:character_name], - "role" => payload["role"] || payload[:role], + "member_name" => fetch(payload, :member_name), + "member_type" => fetch(payload, :member_type), + "eve_id" => fetch(payload, :eve_id), + "role" => fetch(payload, :role), "added_at" => event.timestamp }, "relationships" => %{ - "access_list" => %{ - "data" => %{ - "type" => "access_lists", - "id" => payload["access_list_id"] || payload[:access_list_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } + "access_list" => relationship("access_lists", fetch(payload, :acl_id)), + "map" => map_relationship(event) } } end + # Producer: acl_event_broadcaster.ex:52 defp format_resource_data(%Event{type: :acl_member_removed, payload: payload} = event) do %{ "type" => "access_list_members", - "id" => payload["member_id"] || payload[:member_id], + "id" => rid(fetch(payload, :member_id)), + "attributes" => %{ + "member_name" => fetch(payload, :member_name), + "member_type" => fetch(payload, :member_type), + "eve_id" => fetch(payload, :eve_id) + }, "meta" => %{ "deleted" => true, "deleted_at" => event.timestamp }, "relationships" => %{ - "access_list" => %{ - "data" => %{ - "type" => "access_lists", - "id" => payload["access_list_id"] || payload[:access_list_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } + "access_list" => relationship("access_lists", fetch(payload, :acl_id)), + "map" => map_relationship(event) } } end + # Producer: acl_event_broadcaster.ex:52 defp format_resource_data(%Event{type: :acl_member_updated, payload: payload} = event) do %{ "type" => "access_list_members", - "id" => payload["member_id"] || payload[:member_id], + "id" => rid(fetch(payload, :member_id)), "attributes" => %{ - "role" => payload["role"] || payload[:role], + "member_name" => fetch(payload, :member_name), + "member_type" => fetch(payload, :member_type), + "eve_id" => fetch(payload, :eve_id), + "role" => fetch(payload, :role), "updated_at" => event.timestamp }, "relationships" => %{ - "access_list" => %{ - "data" => %{ - "type" => "access_lists", - "id" => payload["access_list_id"] || payload[:access_list_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } + "access_list" => relationship("access_lists", fetch(payload, :acl_id)), + "map" => map_relationship(event) } } end + # Producer: kills/message_handler.ex:126. The payload is a BATCH - + # %{"solar_system_id", "killmails", "timestamp", "type"} - and every + # per-kill field lives on the elements of "killmails" (built at :298-350). + # + # Guard on presence of the key, not on its value: a batch that legitimately + # carries no kills must render as [], and a present-but-nil "killmails" is a + # malformed batch rather than a kill count. fetch/2 cannot tell absent from + # present-nil, so dispatch on key presence - in both key styles, since the + # producer sends string keys. defp format_resource_data(%Event{type: :map_kill, payload: payload} = event) do - %{ - "type" => "kills", - "id" => payload["killmail_id"] || payload[:killmail_id], - "attributes" => %{ - "killmail_id" => payload["killmail_id"] || payload[:killmail_id], - "victim_character_name" => - payload["victim_character_name"] || payload[:victim_character_name], - "victim_ship_type" => payload["victim_ship_type"] || payload[:victim_ship_type], - "occurred_at" => payload["killmail_time"] || payload[:killmail_time] || event.timestamp - }, - "relationships" => %{ - "system" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} + if Map.has_key?(payload, :killmails) or Map.has_key?(payload, "killmails") do + solar_system_id = fetch(payload, :solar_system_id) + + payload + |> fetch(:killmails) + |> List.wrap() + # A kills resource has no identity but its killmail_id, and the + # producer does not guarantee one: validate_flat_format_kill/1 checks + # required fields with Map.has_key?/2, so a present-but-nil id is + # broadcast. Dropping the element is the only honest option - a null + # id is invalid JSON:API, and any fabricated id (the event ULID, say) + # would collide across the rest of the batch. + |> Enum.reject(&is_nil(fetch(&1, :killmail_id))) + |> Enum.map(fn kill -> + %{ + "type" => "kills", + "id" => rid(fetch(kill, :killmail_id)), + "attributes" => %{ + # The batch's solar_system_id, not the kill's: only the batch id + # is guaranteed to name a system this map contains, since it is + # what routed the event here. + "solar_system_id" => solar_system_id, + "kill_time" => fetch(kill, :kill_time), + "victim_char_id" => fetch(kill, :victim_char_id), + "victim_char_name" => fetch(kill, :victim_char_name), + "victim_corp_ticker" => fetch(kill, :victim_corp_ticker), + "victim_corp_name" => fetch(kill, :victim_corp_name), + "victim_alliance_ticker" => fetch(kill, :victim_alliance_ticker), + "victim_alliance_name" => fetch(kill, :victim_alliance_name), + "victim_ship_type_id" => fetch(kill, :victim_ship_type_id), + "victim_ship_name" => fetch(kill, :victim_ship_name), + "final_blow_char_name" => fetch(kill, :final_blow_char_name), + "attacker_count" => fetch(kill, :attacker_count), + "total_value" => fetch(kill, :total_value), + "npc" => fetch(kill, :npc) + }, + "relationships" => %{"map" => map_relationship(event)} } - } - } + end) + else + format_kill_count(event, payload) + end end + # Producer: map_server_pings_impl.ex:41. This producer does send the + # MapSystem UUID as :system_id, so the system relationship is real here. defp format_resource_data(%Event{type: :rally_point_added, payload: payload} = event) do %{ "type" => "rally_points", - "id" => payload["rally_point_id"] || payload[:rally_point_id], + "id" => rid(fetch(payload, :rally_point_id)), "attributes" => %{ - "name" => payload["name"] || payload[:name], - "description" => payload["description"] || payload[:description], - "created_at" => event.timestamp + "solar_system_id" => fetch(payload, :solar_system_id), + "system_name" => fetch(payload, :system_name), + "character_name" => fetch(payload, :character_name), + "character_eve_id" => fetch(payload, :character_eve_id), + "message" => fetch(payload, :message), + "created_at" => fetch(payload, :created_at) || event.timestamp }, "relationships" => %{ - "system" => %{ - "data" => %{ - "type" => "map_systems", - "id" => payload["system_id"] || payload[:system_id] - } - }, - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } + "system" => relationship("map_systems", fetch(payload, :system_id)), + "map" => map_relationship(event) } } end + # Producer: map_server_pings_impl.ex:94. Note the id key is :id here, not + # :rally_point_id as on the added event. defp format_resource_data(%Event{type: :rally_point_removed, payload: payload} = event) do %{ "type" => "rally_points", - "id" => payload["rally_point_id"] || payload[:rally_point_id], + "id" => rid(fetch(payload, :id)), + "attributes" => %{ + "solar_system_id" => fetch(payload, :solar_system_id), + "system_name" => fetch(payload, :system_name), + "character_name" => fetch(payload, :character_name), + "character_eve_id" => fetch(payload, :character_eve_id) + }, "meta" => %{ "deleted" => true, "deleted_at" => event.timestamp }, "relationships" => %{ - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } + "system" => relationship("map_systems", fetch(payload, :system_id)), + "map" => map_relationship(event) } } end @@ -431,14 +473,82 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do "type" => "events", "id" => event.id, "attributes" => payload, - "relationships" => %{ - "map" => %{ - "data" => %{"type" => "maps", "id" => event.map_id} - } - } + "relationships" => %{"map" => map_relationship(event)} + } + end + + # --- Payload helpers ------------------------------------------------------- + # + # These live after the last format_resource_data/1 clause on purpose: + # interleaving them triggers "clauses with the same name and arity should be + # grouped together", which `mix compile` reports and `credo` does not. + + # Reads a key from a payload that may be atom-keyed, string-keyed, or a + # struct. Structs do not implement Access, so `payload[key]` raises for the + # Api.Character structs that character events broadcast. Using Map.fetch/2 + # rather than `a || b` also preserves `false`, which the previous idiom + # silently converted to nil. + defp fetch(payload, key) when is_atom(key) do + case Map.fetch(payload, key) do + {:ok, value} -> value + :error -> Map.get(payload, Atom.to_string(key)) + end + end + + # JSON:API requires a string id on every resource object. + defp rid(nil), do: nil + defp rid(value) when is_binary(value), do: value + defp rid(value), do: to_string(value) + + # System events identify a map_systems record when the producer sent its + # UUID. Events broadcast before that producer change - replayed from a + # queue, say - have no UUID, and a null id is not valid JSON:API. Those fall + # back to the aggregate shape used by the signature events: the event ULID as + # identity, under a type that makes no claim to be a UUID-keyed resource. + defp system_identity(%Event{} = event, payload) do + case rid(fetch(payload, :system_id)) do + nil -> {"system_events", event.id} + system_id -> {"map_systems", system_id} + end + end + + defp map_relationship(%Event{map_id: map_id}) do + relationship("maps", map_id) + end + + # Producer: kills/message_handler.ex:111. Kill-count updates reuse :map_kill + # with a count and no "killmails" key. A summary names no single kill, so the + # event ULID is the identity. + defp format_kill_count(%Event{} = event, payload) do + %{ + "type" => "kill_counts", + "id" => event.id, + "attributes" => %{ + "solar_system_id" => fetch(payload, :solar_system_id), + "count" => fetch(payload, :count), + "updated_at" => event.timestamp + }, + "relationships" => %{"map" => map_relationship(event)} } end + # An empty to-one relationship is represented as "data": null. Emitting + # %{"type" => t, "id" => nil} instead would be an invalid identifier object. + defp relationship(type, id) do + case rid(id) do + nil -> %{"data" => nil} + id -> %{"data" => %{"type" => type, "id" => id}} + end + end + + # Projects a character payload through @character_attribute_keys. Never pass + # a character payload through wholesale - it carries OAuth credentials. + defp character_attrs(payload) do + Map.new(@character_attribute_keys, fn key -> + {Atom.to_string(key), fetch(payload, key)} + end) + end + # Legacy event formatting (for events already in map format) defp format_legacy_resource_data(event) do event_type = event["type"] || "unknown" @@ -550,7 +660,8 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do ] -> "updated" - :signatures_updated -> + # Both bulk types summarise many records under one event. + type when type in [:signatures_updated, :characters_updated] -> "bulk_updated" :map_kill -> diff --git a/lib/wanderer_app/external_events/map_event_relay.ex b/lib/wanderer_app/external_events/map_event_relay.ex index 30e563eed..b2475bcbb 100644 --- a/lib/wanderer_app/external_events/map_event_relay.ex +++ b/lib/wanderer_app/external_events/map_event_relay.ex @@ -159,6 +159,10 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do WebhookDispatcher.dispatch_event(event.map_id, event) + # Also a cast, so Discord config lookups and formatting never delay SSE or + # generic webhook delivery on this process. + WandererApp.ExternalEvents.DiscordDispatcher.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) diff --git a/lib/wanderer_app/helpers/config.ex b/lib/wanderer_app/helpers/config.ex index 3cee7f3c7..1740c2068 100644 --- a/lib/wanderer_app/helpers/config.ex +++ b/lib/wanderer_app/helpers/config.ex @@ -23,4 +23,56 @@ defmodule WandererApp.ConfigHelpers do end end end + + @fly_sentinel "NOT_FLY_APP" + + @doc """ + Resolves the external hostname. + + `FLY_APP_NAME` is a **fallback**, not an override. An operator who sets + `PHX_HOST` explicitly gets it even on Fly, which is what makes a custom + domain โ€” and therefore a working EVE OAuth callback โ€” possible. When + `PHX_HOST` is unset the behaviour is unchanged from before this function + existed. + + An explicitly-empty `PHX_HOST` is treated as unset. Before this function + existed it produced `http://:8000`, which is not a usable URL for anyone; + `localhost` is the same value an unset variable gives. Contrast + `resolve_web_app_url/4`, where an empty string must pass through so the + caller's scheme check still raises. + """ + def resolve_host(phx_host, fly_app_name) + + def resolve_host(phx_host, _fly_app_name) when is_binary(phx_host) and phx_host != "", + do: phx_host + + def resolve_host(_phx_host, fly_app_name) + when is_binary(fly_app_name) and fly_app_name != "" and fly_app_name != @fly_sentinel, + do: "#{fly_app_name}.fly.dev" + + def resolve_host(_phx_host, _fly_app_name), do: "localhost" + + @doc """ + Resolves the externally-visible base URL. + + Same rule as `resolve_host/2`: an explicit `WEB_APP_URL` always wins. On Fly + without one, https is assumed because the Fly edge terminates TLS. + + Note the first clause matches **any** binary, including `""`. That is + deliberate and differs from `resolve_host/2`. `WEB_APP_URL=` in a `.env` + file yields `""`, not nil, and the caller parses the result and raises when + the scheme is missing. + """ + def resolve_web_app_url(web_app_url, host, port, fly_app_name) + + def resolve_web_app_url(web_app_url, _host, _port, _fly_app_name) + when is_binary(web_app_url), + do: web_app_url + + def resolve_web_app_url(_web_app_url, host, _port, fly_app_name) + when is_binary(fly_app_name) and fly_app_name != "" and fly_app_name != @fly_sentinel, + do: "https://#{host}" + + def resolve_web_app_url(_web_app_url, host, port, _fly_app_name), + do: "http://#{host}:#{port}" end diff --git a/lib/wanderer_app/kills/client.ex b/lib/wanderer_app/kills/client.ex index 8ec49fbe3..c41bce693 100644 --- a/lib/wanderer_app/kills/client.ex +++ b/lib/wanderer_app/kills/client.ex @@ -12,8 +12,18 @@ defmodule WandererApp.Kills.Client do alias WandererApp.Kills.Subscription.{Manager, MapIntegration} alias Phoenix.Channels.GenSocketClient - # Simple retry configuration - inline like character module - @retry_delays [5_000, 10_000, 30_000, 60_000] + # Reconnect backoff: exponential from 1s to a 60s ceiling, plus ~30% jitter. + # The jitter matters operationally โ€” without it every instance that lost the + # upstream at the same moment reconnects at the same moment, turning one blip + # into a synchronized thundering herd against the kills service. + @retry_base_delay_ms 1_000 + @retry_max_delay_ms 60_000 + @retry_jitter_fraction 0.3 + # A floor, so a pathological jitter draw can never schedule an immediate retry. + @retry_min_delay_ms 100 + # Caps the exponent so `Integer.pow/2` cannot blow up if retry_count is ever + # raised well above @max_retries. 2^16 * 1s is already far past the ceiling. + @retry_max_exponent 16 @max_retries 10 # Check every 30 seconds @health_check_interval :timer.seconds(30) @@ -85,6 +95,44 @@ defmodule WandererApp.Kills.Client do :ok end + @doc """ + Delay before the next reconnect attempt, in milliseconds. + + Exponential from #{@retry_base_delay_ms}ms, capped at #{@retry_max_delay_ms}ms, + with a jitter offset of up to ยฑ#{trunc(@retry_jitter_fraction * 100)}%. + + ## Why `rand_fun` is an argument + + Public and injectable on purpose. With the random source pinned a test can + assert the *exact* delay sequence; a function that called `:rand.uniform/1` + internally could only be range-asserted, and a range assertion does not + distinguish a ceiling applied before jitter from one applied after. The + before-jitter version silently schedules retries past the ceiling. + + `rand_fun` follows the `:rand.uniform/1` contract: given `n`, it returns an + integer in `1..n`. + """ + @spec retry_delay_ms(non_neg_integer(), (pos_integer() -> pos_integer())) :: pos_integer() + def retry_delay_ms(retry_count, rand_fun \\ &:rand.uniform/1) + when is_integer(retry_count) and retry_count >= 0 and is_function(rand_fun, 1) do + base = + @retry_base_delay_ms + |> Kernel.*(Integer.pow(2, min(retry_count, @retry_max_exponent))) + |> min(@retry_max_delay_ms) + + span = trunc(base * @retry_jitter_fraction) + + # rand_fun.(2 * span + 1) is in 1..2*span+1, so the offset is in -span..span. + # The +1 keeps the argument positive when span is 0. + offset = rand_fun.(2 * span + 1) - span - 1 + + # The ceiling is re-applied HERE, after the offset. Applying it only to + # `base` above would let the top of the jitter range exceed it. + (base + offset) + |> min(@retry_max_delay_ms) + |> max(@retry_min_delay_ms) + end + # Server callbacks @impl true def init(_opts) do @@ -392,7 +440,21 @@ defmodule WandererApp.Kills.Client do {:error, reason} -> Logger.error("[Client] Connection failed: #{inspect(reason)}") - schedule_retry(%{state | connecting: false, last_error: reason}) + state = %{state | connecting: false, last_error: reason} + + # Gated on `should_retry?/1` for the same reason the async failure path + # at `handle_info({:socket_error, ...})` is: scheduling unconditionally + # means an exhausted retry budget still queues another reconnect, so the + # 15-minute retry-cycle cooldown in `check_health/1` never gets to run. + if should_retry?(state) do + schedule_retry(state) + else + Logger.error( + "[Client] Max retry attempts (#{@max_retries}) reached. Will not retry automatically." + ) + + state + end end end @@ -421,23 +483,24 @@ defmodule WandererApp.Kills.Client do disconnected: false } - # GenSocketClient expects transport_opts to be wrapped in a specific format - opts = [ - transport_opts: [ - # 10 second connection timeout - timeout: 10_000, - tcp_opts: [ - # TCP connection timeout - connect_timeout: 10_000, - send_timeout: 5_000, - recv_timeout: 5_000 - ] - ] - ] + # GenSocketClient passes :transport_opts verbatim to the transport's + # start_link/2 (gen_socket_client.ex:247, :385). + # + # :socket_opts reaches the socket only via + # WandererApp.Kills.Transport.WebSocketClient โ€” upstream's transport filters + # it out. + # + # The connect/send/recv timeouts that used to sit here were removed: they + # never applied. Upstream's handler init/1 reads only :keepalive, and + # websocket_client 1.5.0 hardcodes its connect timeout to 6000ms + # (websocket_client.erl:275). They were adjustable-looking and inert. + socket_opts = if WandererApp.Env.wanderer_kills_ipv6?(), do: [:inet6], else: [] + + opts = [transport_opts: [socket_opts: socket_opts]] case GenSocketClient.start_link( __MODULE__.Handler, - Phoenix.Channels.GenSocketClient.Transport.WebSocketClient, + WandererApp.Kills.Transport.WebSocketClient, handler_state, opts ) do @@ -484,7 +547,10 @@ defmodule WandererApp.Kills.Client do state end - delay = Enum.at(@retry_delays, min(state.retry_count, length(@retry_delays) - 1)) + # `state.retry_count` is the PRE-increment value, matching the previous + # `Enum.at/2` indexing: the first retry after a disconnect backs off by one + # base interval, not two. + delay = retry_delay_ms(state.retry_count) timer_ref = Process.send_after(self(), :retry_connection, delay) %{state | retry_timer_ref: timer_ref, retry_count: new_retry_count} diff --git a/lib/wanderer_app/kills/message_handler.ex b/lib/wanderer_app/kills/message_handler.ex index b2c2fedd1..a24e9905b 100644 --- a/lib/wanderer_app/kills/message_handler.ex +++ b/lib/wanderer_app/kills/message_handler.ex @@ -177,15 +177,18 @@ defmodule WandererApp.Kills.MessageHandler do @type killmail :: map() @type adapter_result :: {:ok, killmail()} | {:error, term()} + @doc false + # Public only so the flattening logic can be tested directly; production + # callers go through `process_killmail_update/1`. @spec adapt_kill_data(any()) :: adapter_result() # Pattern match on zkillboard format - not supported - defp adapt_kill_data(%{"killID" => kill_id}) do + def adapt_kill_data(%{"killID" => kill_id}) do Logger.warning("[MessageHandler] Zkillboard format not supported: killID=#{kill_id}") {:error, :zkillboard_format_not_supported} end # Pattern match on flat format - already adapted - defp adapt_kill_data(%{"victim_char_id" => _} = kill) do + def adapt_kill_data(%{"victim_char_id" => _} = kill) do validated_kill = validate_flat_format_kill(kill) if map_size(validated_kill) > 0 do @@ -197,14 +200,14 @@ defmodule WandererApp.Kills.MessageHandler do end # Pattern match on nested format with valid structure - defp adapt_kill_data( - %{ - "killmail_id" => killmail_id, - "kill_time" => _kill_time, - "victim" => victim - } = kill - ) - when is_map(victim) do + def adapt_kill_data( + %{ + "killmail_id" => killmail_id, + "kill_time" => _kill_time, + "victim" => victim + } = kill + ) + when is_map(victim) do # Validate and normalize IDs first with {:ok, valid_killmail_id} <- validate_killmail_id(killmail_id), {:ok, valid_system_id} <- get_and_validate_system_id(kill) do @@ -232,7 +235,7 @@ defmodule WandererApp.Kills.MessageHandler do end # Invalid data type - defp adapt_kill_data(invalid_data) do + def adapt_kill_data(invalid_data) do data_type = if(is_nil(invalid_data), do: "nil", else: "#{inspect(invalid_data)}") Logger.warning("[MessageHandler] Invalid data type: #{data_type}") {:error, :invalid_format} @@ -260,7 +263,9 @@ defmodule WandererApp.Kills.MessageHandler do @spec adapt_nested_format_kill(map()) :: map() defp adapt_nested_format_kill(kill) do victim = kill["victim"] - attackers = Map.get(kill, "attackers", []) + # Raw, undefaulted lookup: nil means "attackers" was absent (or explicitly + # nil), which is different from a payload that carried an empty list. + attackers = kill["attackers"] zkb = Map.get(kill, "zkb", %{}) # Validate attackers is a list @@ -273,6 +278,7 @@ defmodule WandererApp.Kills.MessageHandler do |> add_victim_data(victim) |> add_final_blow_attacker_data(final_blow_attacker) |> add_kill_statistics(attackers_list, zkb) + |> maybe_add_attacker_identity_data(attackers, attackers_list) # Validate that critical output fields are present case validate_required_output_fields(adapted_kill) do @@ -320,18 +326,24 @@ defmodule WandererApp.Kills.MessageHandler do end @spec add_final_blow_attacker_data(map(), map()) :: map() - defp add_final_blow_attacker_data(acc, attacker) do + defp add_final_blow_attacker_data(acc, attacker), + do: add_prefixed_attacker_data(acc, attacker, "final_blow") + + # Shared by the final-blow and top-damage attackers so the two can never + # drift in how names, tickers and ids are read from an attacker map. + @spec add_prefixed_attacker_data(map(), map(), String.t()) :: map() + defp add_prefixed_attacker_data(acc, attacker, prefix) do attacker_data = %{ - "final_blow_char_id" => attacker["character_id"], - "final_blow_char_name" => get_character_name(attacker), - "final_blow_corp_id" => attacker["corporation_id"], - "final_blow_corp_ticker" => get_corp_ticker(attacker), - "final_blow_corp_name" => get_corp_name(attacker), - "final_blow_alliance_id" => attacker["alliance_id"], - "final_blow_alliance_ticker" => get_alliance_ticker(attacker), - "final_blow_alliance_name" => get_alliance_name(attacker), - "final_blow_ship_type_id" => attacker["ship_type_id"], - "final_blow_ship_name" => get_ship_name(attacker) + "#{prefix}_char_id" => attacker["character_id"], + "#{prefix}_char_name" => get_character_name(attacker), + "#{prefix}_corp_id" => attacker["corporation_id"], + "#{prefix}_corp_ticker" => get_corp_ticker(attacker), + "#{prefix}_corp_name" => get_corp_name(attacker), + "#{prefix}_alliance_id" => attacker["alliance_id"], + "#{prefix}_alliance_ticker" => get_alliance_ticker(attacker), + "#{prefix}_alliance_name" => get_alliance_name(attacker), + "#{prefix}_ship_type_id" => attacker["ship_type_id"], + "#{prefix}_ship_name" => get_ship_name(attacker) } Map.merge(acc, attacker_data) @@ -346,6 +358,72 @@ defmodule WandererApp.Kills.MessageHandler do }) end + # Attacker identity, retained for Discord involvement matching. Deliberately + # separate from `add_kill_statistics/3`, which is about aggregates and + # discards the attacker list after taking its length. + # + # Every field produced here is OPTIONAL: `@required_output_fields` must not + # grow. Empty lists are a valid result (an all-NPC kill has no pilots), and + # nil top-damage fields are valid (an all-NPC kill has no top-damage pilot). + # + # Only attach these keys when the payload genuinely carried an "attackers" + # list โ€” even an empty one. An absent or malformed "attackers" value means + # "we don't know who attacked", which Task 7 must be able to tell apart from + # "we know, and it was nobody": leaving all six keys absent signals the + # former, an empty list signals the latter. + @spec maybe_add_attacker_identity_data(map(), any(), list()) :: map() + defp maybe_add_attacker_identity_data(acc, attackers, attackers_list) + when is_list(attackers) do + add_attacker_identity_data(acc, attackers_list) + end + + defp maybe_add_attacker_identity_data(acc, _attackers, _attackers_list), do: acc + + @spec add_attacker_identity_data(map(), list()) :: map() + defp add_attacker_identity_data(acc, attackers_list) do + top_damage_attacker = find_top_damage_attacker(attackers_list) + + acc + |> Map.merge(%{ + "attacker_char_ids" => collect_ids(attackers_list, "character_id"), + "attacker_corp_ids" => collect_ids(attackers_list, "corporation_id") + }) + |> add_prefixed_attacker_data(top_damage_attacker, "top_damage") + end + + # NPC attackers carry no character or corporation id, so nils are dropped + # rather than retained as a nil member that could never match anything. + # + # Ids are normalized to integers here, at the source, so every downstream + # consumer (Discord.Matcher's tracked-pilot set, Task 7's involvement + # check) can compare without coercion. A payload carrying a string id + # (`"character_id" => "91000001"`) would otherwise put a binary in this + # list, silently failing to match an integer set. + @spec collect_ids(list(), String.t()) :: [integer()] + defp collect_ids(attackers_list, key) do + attackers_list + |> Enum.filter(&is_map/1) + |> Enum.map(&Map.get(&1, key)) + |> Enum.map(&normalize_id/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + # Wire values are normally integers already; a numeric string is accepted + # (parsed in full โ€” `"12345abc"` and `" 12345"` are rejected, not + # truncated, via the `{id, ""}` guard) and anything else is dropped. + @spec normalize_id(term()) :: integer() | nil + defp normalize_id(id) when is_integer(id), do: id + + defp normalize_id(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} -> parsed + _ -> nil + end + end + + defp normalize_id(_), do: nil + # Critical fields that the frontend expects to be present in killmail data @required_output_fields [ "killmail_id", @@ -390,6 +468,26 @@ defmodule WandererApp.Kills.MessageHandler do defp find_final_blow_attacker(_), do: %{} + # Mirrors `find_final_blow_attacker/1`: returns `%{}` when there is nothing + # to pick, so the downstream extractor yields nils rather than crashing. + @spec find_top_damage_attacker(list(map()) | any()) :: map() + defp find_top_damage_attacker([]), do: %{} + + defp find_top_damage_attacker(attackers) when is_list(attackers) do + attackers + |> Enum.filter(&is_map/1) + |> Enum.max_by(&damage_done/1, fn -> %{} end) + end + + defp find_top_damage_attacker(_), do: %{} + + # `damage_done` is occasionally absent or non-numeric in upstream payloads; + # treat those attackers as having dealt no damage rather than crashing the + # whole killmail's adaptation. + @spec damage_done(map()) :: number() + defp damage_done(%{"damage_done" => damage}) when is_number(damage), do: damage + defp damage_done(_), do: 0 + # Generic field extraction with multiple possible field names @spec extract_field(map(), list(String.t())) :: String.t() | nil defp extract_field(data, field_names) when is_map(data) and is_list(field_names) do diff --git a/lib/wanderer_app/kills/subscription/system_map_index.ex b/lib/wanderer_app/kills/subscription/system_map_index.ex index 23e999673..db4fd4ec8 100644 --- a/lib/wanderer_app/kills/subscription/system_map_index.ex +++ b/lib/wanderer_app/kills/subscription/system_map_index.ex @@ -100,7 +100,14 @@ defmodule WandererApp.Kills.Subscription.SystemMapIndex do index = maps |> Enum.reduce(%{}, fn map, acc -> - case WandererApp.MapSystemRepo.get_all_by_map(map.id) do + # Visible systems ONLY. Removal from a map is a soft delete + # (`MapSystemRepo.remove_from_map/2` sets `visible: false`), so + # `get_all_by_map/1` here indexed every system the map had ever + # contained and kills kept broadcasting for removed systems forever. + # The sibling `MapIntegration.get_tracked_system_ids/0` already uses + # this variant, and `MapSystem` carries a partial index for exactly + # this filter (`api/map_system.ex:44`). + case WandererApp.MapSystemRepo.get_visible_by_map(map.id) do {:ok, systems} -> # Add this map to each system's list Enum.reduce(systems, acc, fn system, acc2 -> diff --git a/lib/wanderer_app/kills/transport/web_socket_client.ex b/lib/wanderer_app/kills/transport/web_socket_client.ex new file mode 100644 index 000000000..70c44aa6e --- /dev/null +++ b/lib/wanderer_app/kills/transport/web_socket_client.ex @@ -0,0 +1,47 @@ +defmodule WandererApp.Kills.Transport.WebSocketClient do + @moduledoc """ + A thin wrapper around `Phoenix.Channels.GenSocketClient.Transport.WebSocketClient` + that also forwards `:socket_opts` through to `:websocket_client`. + + Upstream splits transport options on exactly `[:extra_headers, :ssl_verify]` + (`web_socket_client.ex:18`) and passes everything else through as the handler + state, so `:socket_opts` โ€” the key `:websocket_client` actually reads + (`websocket_client.erl:195`) โ€” never reaches the socket. + + Without this, `:inet6` cannot be set, and Fly's 6PN `.internal` addresses are + IPv6-only while Erlang's `gen_tcp` resolves hostnames as IPv4 by default. + + This module couples to an upstream private contract: the handler-state + argument is `[socket, transport_options]` (`web_socket_client.ex:48`). + `phoenix_gen_socket_client` is pinned in `mix.exs` for that reason. Delete + this module once `:socket_opts` is added to upstream's split list. + """ + @behaviour Phoenix.Channels.GenSocketClient.Transport + + @upstream Phoenix.Channels.GenSocketClient.Transport.WebSocketClient + @websocket_client Application.compile_env( + :wanderer_app, + :websocket_client_module, + :websocket_client + ) + @ws_opts [:extra_headers, :ssl_verify, :socket_opts] + + @doc """ + Partitions transport options into `{websocket_client_options, handler_state}`. + + Public only so it can be tested directly; not part of the behaviour. + """ + def split_opts(transport_options), do: Keyword.split(transport_options, @ws_opts) + + @impl true + def start_link(url, transport_options) do + {ws_opts, rest} = split_opts(transport_options) + + url + |> to_charlist() + |> @websocket_client.start_link(@upstream, [self(), rest], ws_opts) + end + + @impl true + defdelegate push(pid, frame), to: @upstream +end diff --git a/lib/wanderer_app/map.ex b/lib/wanderer_app/map.ex index 07f33819a..a2357d010 100644 --- a/lib/wanderer_app/map.ex +++ b/lib/wanderer_app/map.ex @@ -251,6 +251,7 @@ defmodule WandererApp.Map do else case update_map(map_id, %{characters: new_character_ids ++ current_characters}) do {:commit, map} -> + WandererApp.ExternalEvents.Discord.Matcher.invalidate_tracked(map_id) map _ -> @@ -272,6 +273,8 @@ defmodule WandererApp.Map do map_id |> update_map(%{characters: [character_id | characters]}) + WandererApp.ExternalEvents.Discord.Matcher.invalidate_tracked(map_id) + :ok _ -> @@ -296,6 +299,8 @@ defmodule WandererApp.Map do map_id |> update_map(%{characters: characters |> Enum.reject(fn id -> id == character_id end)}) + WandererApp.ExternalEvents.Discord.Matcher.invalidate_tracked(map_id) + :ok _ -> diff --git a/lib/wanderer_app/map/README.md b/lib/wanderer_app/map/README.md new file mode 100644 index 000000000..69b875cbc --- /dev/null +++ b/lib/wanderer_app/map/README.md @@ -0,0 +1,89 @@ +# Map Cleanup Systems + +## Overview + +The application has two signature cleanup systems that operate in parallel: + +### 1. Upstream GarbageCollector (Daily Batch) + +- **Location:** `lib/wanderer_app/map/map_garbage_collector.ex` +- **Schedule:** Daily via Quantum (`@daily`) +- **Thresholds:** Chain passages: 7 days, Signatures: 14 days +- **Scope:** All signatures globally +- **Configuration:** Hardcoded (not configurable) + +### 2. Zoo On-Demand Cleanup (User-Triggered) + +- **Location:** `lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex` +- **Trigger:** When user views or updates signatures +- **Thresholds:** Wormholes: 24h, Other: 72h (configurable) +- **Scope:** Per-system +- **Configuration:** Environment variables + +## Configuration (Zoo) + +```elixir +# config/config.exs (defaults); env-var overrides are applied in config/runtime.exs +config :wanderer_app, :signatures, + wormhole_expiration_hours: 24, # SIGNATURE_WORMHOLE_EXPIRATION_HOURS + default_expiration_hours: 72, # SIGNATURE_DEFAULT_EXPIRATION_HOURS + preserve_connected: true + +config :wanderer_app, :signature_cleanup, + max_age_hours: 24 +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SIGNATURE_WORMHOLE_EXPIRATION_HOURS` | 24 | Hours before wormhole signatures expire (0 = never) | +| `SIGNATURE_DEFAULT_EXPIRATION_HOURS` | 72 | Hours before non-wormhole signatures expire (0 = never) | + +## How They Interact + +- Zoo cleanup runs first (on user interaction) with aggressive thresholds +- Upstream cleanup runs daily and catches anything zoo missed +- No conflict risk: zoo deletes before upstream ever sees the signatures +- Upstream acts as a safety net for never-accessed systems + +## Cleanup Logic (Zoo) + +The zoo cleanup (`WandererApp.Map.SignatureCleanup.cleanup/1`, usually invoked +asynchronously via `cleanup_async/1`) works as follows: + +1. Loads all signatures for the system +2. Calculates cutoff times based on signature type: + - Wormhole signatures: `wormhole_expiration_hours` (default 24h) + - Other signatures: `default_expiration_hours` (default 72h) +3. Optionally preserves connected signatures (`preserve_connected: true`) +4. Deletes expired signatures and broadcasts updates + +When expiration is disabled (both hour settings are 0), the per-type windows are +skipped and a single `max_age_hours` sweep runs instead as a safety net. + +## Disabling Options + +To disable per-type expiration: set both expiration hours to 0 + +```bash +SIGNATURE_WORMHOLE_EXPIRATION_HOURS=0 +SIGNATURE_DEFAULT_EXPIRATION_HOURS=0 +``` + +This does **not** disable zoo cleanup. As described above, zero on both values +switches the sweep to the single `max_age_hours` backstop (24h by default), so +signatures older than that are still deleted. `max_age_hours` has no environment +variable โ€” to widen or effectively disable the backstop, raise it in +`config/config.exs`: + +```elixir +config :wanderer_app, :signature_cleanup, max_age_hours: 87_600 +``` + +To disable upstream cleanup: Comment out scheduler jobs in `config/runtime.exs`: + +```elixir +# {"@daily", {WandererApp.Map.GarbageCollector, :cleanup_chain_passages, []}}, +# {"@daily", {WandererApp.Map.GarbageCollector, :cleanup_system_signatures, []}} +``` diff --git a/lib/wanderer_app/map/intel_sync.ex b/lib/wanderer_app/map/intel_sync.ex new file mode 100644 index 000000000..c611abd86 --- /dev/null +++ b/lib/wanderer_app/map/intel_sync.ex @@ -0,0 +1,277 @@ +defmodule WandererApp.Map.IntelSync do + @moduledoc """ + Copies intel from a source map to a subscriber map for a given solar system. + + Called when a system becomes visible on a subscriber map (sync-on-visibility), + or when a user manually triggers a re-sync via the sync icon. + + Intel fields: custom_name, description, tag, temporary_name, labels, status. + Also syncs comments and structures (marked with inherited_from_map_id). + """ + + require Logger + + alias WandererApp.MapSystemRepo + + @intel_fields [:custom_name, :description, :tag, :temporary_name, :labels, :status] + + @doc "Returns the list of system fields considered intel for syncing." + def intel_fields, do: @intel_fields + + @doc """ + Syncs intel for a single system from source map to subscriber map. + Copies system metadata fields, comments, and structures. + + Returns: + - {:ok, updated_system} on successful sync + - {:ok, :disabled} if intel sharing is disabled + - {:ok, :no_source_data} if source map has no data for this system + - {:ok, :subscriber_not_found} if subscriber map has no matching system + - {:error, reason} on failure + """ + def sync_system(subscriber_map_id, source_map_id, solar_system_id) do + if WandererApp.Env.intel_sharing_enabled?() do + do_sync_system(subscriber_map_id, source_map_id, solar_system_id) + else + {:ok, :disabled} + end + end + + @doc """ + Syncs intel for all visible systems on a subscriber map from its source. + Used when intel_source_map_id is first configured (backfill). + + Returns: + - `{:ok, synced_count}` when all systems synced successfully (or were skipped). + `synced_count` is the number of systems whose intel was actually copied. + - `{:ok, synced_count, errors}` on partial failure. `synced_count` is the number + of systems successfully synced, and `errors` is a list of + `{solar_system_id, reason}` tuples for each system that failed to sync. + - `{:ok, :disabled}` if intel sharing is disabled. + - `{:error, :list_systems_failed}` if the visible systems could not be loaded. + """ + def sync_all_visible_systems(subscriber_map_id, source_map_id) do + if WandererApp.Env.intel_sharing_enabled?() do + case MapSystemRepo.get_visible_by_map(subscriber_map_id) do + {:ok, systems} -> + results = + Enum.map(systems, fn system -> + {system.solar_system_id, + do_sync_system(subscriber_map_id, source_map_id, system.solar_system_id)} + end) + + {synced_count, skipped_count, errors} = + Enum.reduce(results, {0, 0, []}, fn + {_sid, {:ok, %{} = _system}}, {ok, skip, errs} -> + {ok + 1, skip, errs} + + {_sid, {:ok, reason}}, {ok, skip, errs} when is_atom(reason) -> + {ok, skip + 1, errs} + + {sid, {:error, reason}}, {ok, skip, errs} -> + {ok, skip, [{sid, reason} | errs]} + end) + + errors = Enum.reverse(errors) + + if errors == [] do + Logger.debug(fn -> + "Intel sync backfill for map #{subscriber_map_id}: #{synced_count} synced, #{skipped_count} skipped" + end) + + {:ok, synced_count} + else + Logger.error(fn -> + "Intel sync backfill for map #{subscriber_map_id}: #{synced_count} synced, " <> + "#{skipped_count} skipped, #{length(errors)} errors: #{inspect(errors)}" + end) + + {:ok, synced_count, errors} + end + + error -> + Logger.error(fn -> + "Failed to list visible systems for backfill: #{inspect(error)}" + end) + + {:error, :list_systems_failed} + end + else + {:ok, :disabled} + end + end + + defp do_sync_system(subscriber_map_id, source_map_id, solar_system_id) do + with {:source, {:ok, source_system}} <- + {:source, MapSystemRepo.get_by_map_and_solar_system_id(source_map_id, solar_system_id)}, + {:subscriber, {:ok, subscriber_system}} <- + {:subscriber, + MapSystemRepo.get_by_map_and_solar_system_id(subscriber_map_id, solar_system_id)} do + intel_attrs = Map.take(source_system, @intel_fields) + + case WandererApp.Api.MapSystem.update_intel(subscriber_system, intel_attrs) do + {:ok, updated_system} -> + comments_result = + sync_inherited_records( + subscriber_system.id, + source_system.id, + source_map_id, + WandererApp.Api.MapSystemComment, + &comment_attrs/3 + ) + + structures_result = + sync_inherited_records( + subscriber_system.id, + source_system.id, + source_map_id, + WandererApp.Api.MapSystemStructure, + &structure_attrs/3 + ) + + case {comments_result, structures_result} do + {:ok, :ok} -> + {:ok, updated_system} + + {{:error, reason}, _} -> + Logger.error(fn -> + "Failed to sync comments for solar_system #{solar_system_id} " <> + "from map #{source_map_id} to #{subscriber_map_id}: #{inspect(reason)}" + end) + + {:error, reason} + + {_, {:error, reason}} -> + Logger.error(fn -> + "Failed to sync structures for solar_system #{solar_system_id} " <> + "from map #{source_map_id} to #{subscriber_map_id}: #{inspect(reason)}" + end) + + {:error, reason} + end + + {:error, reason} -> + Logger.error(fn -> + "Failed to sync intel for system #{solar_system_id}: #{inspect(reason)}" + end) + + {:error, reason} + end + else + {:source, {:error, :not_found}} -> + {:ok, :no_source_data} + + {:subscriber, {:error, :not_found}} -> + Logger.debug(fn -> + "Intel sync skipped for solar_system #{solar_system_id}: subscriber system not found on map #{subscriber_map_id}" + end) + + {:ok, :subscriber_not_found} + + {step, error} -> + Logger.debug(fn -> + "Intel sync skipped for solar_system #{solar_system_id} at #{step}: #{inspect(error)}" + end) + + {:error, error} + end + end + + defp sync_inherited_records( + subscriber_system_id, + source_system_id, + source_map_id, + api_module, + attrs_fn + ) do + with :ok <- delete_inherited(subscriber_system_id, source_map_id, api_module) do + copy_from_source( + subscriber_system_id, + source_system_id, + source_map_id, + api_module, + attrs_fn + ) + end + end + + defp delete_inherited(subscriber_system_id, source_map_id, api_module) do + case api_module.inherited_by_system(subscriber_system_id, source_map_id) do + {:ok, inherited_records} -> + errors = + inherited_records + |> Enum.reduce([], fn record, acc -> + case api_module.destroy(record) do + :ok -> acc + {:ok, _} -> acc + {:error, reason} -> [reason | acc] + end + end) + + case errors do + [] -> :ok + details -> {:error, {:delete_failed, Enum.reverse(details)}} + end + + {:error, reason} -> + {:error, {:delete_failed, [reason]}} + end + end + + defp copy_from_source( + subscriber_system_id, + source_system_id, + source_map_id, + api_module, + attrs_fn + ) do + case api_module.by_system_id(source_system_id) do + {:ok, source_records} -> + errors = + source_records + |> Enum.reject(& &1.inherited_from_map_id) + |> Enum.reduce([], fn record, acc -> + case api_module.create(attrs_fn.(record, subscriber_system_id, source_map_id)) do + {:ok, _} -> acc + {:error, reason} -> [reason | acc] + end + end) + + case errors do + [] -> :ok + details -> {:error, {:create_failed, Enum.reverse(details)}} + end + + {:error, reason} -> + {:error, {:create_failed, [reason]}} + end + end + + defp comment_attrs(comment, subscriber_system_id, source_map_id) do + %{ + system_id: subscriber_system_id, + character_id: comment.character_id, + text: comment.text, + inherited_from_map_id: source_map_id + } + end + + defp structure_attrs(structure, subscriber_system_id, source_map_id) do + %{ + system_id: subscriber_system_id, + solar_system_name: structure.solar_system_name, + solar_system_id: structure.solar_system_id, + structure_type_id: structure.structure_type_id, + structure_type: structure.structure_type, + character_eve_id: structure.character_eve_id, + name: structure.name, + notes: structure.notes, + owner_name: structure.owner_name, + owner_ticker: structure.owner_ticker, + owner_id: structure.owner_id, + status: structure.status, + end_time: structure.end_time, + inherited_from_map_id: source_map_id + } + end +end diff --git a/lib/wanderer_app/map/map_garbage_collector.ex b/lib/wanderer_app/map/map_garbage_collector.ex index f855d89d2..b9fc1e5cf 100644 --- a/lib/wanderer_app/map/map_garbage_collector.ex +++ b/lib/wanderer_app/map/map_garbage_collector.ex @@ -13,11 +13,19 @@ defmodule WandererApp.Map.GarbageCollector do def cleanup_chain_passages() do Logger.info("Start cleanup old map chain passages...") - WandererApp.Api.MapChainPassages - |> Ash.Query.filter(updated_at: [less_than: get_cutoff_time(@one_week_seconds)]) - |> Ash.bulk_destroy!(:destroy, %{}, batch_size: 100) + # Use return_errors? to handle stale records gracefully + result = + WandererApp.Api.MapChainPassages + |> Ash.Query.filter(updated_at: [less_than: get_cutoff_time(@one_week_seconds)]) + |> Ash.bulk_destroy(:destroy, %{}, batch_size: 100, return_errors?: true) - @logger.info(fn -> "All map chain passages processed" end) + case result do + %Ash.BulkResult{status: :error, errors: errors} -> + Logger.error("Failed to cleanup chain passages: #{inspect(errors)}") + + %Ash.BulkResult{errors: errors} -> + report_bulk_errors(errors, "chain passages") + end :ok end @@ -25,14 +33,49 @@ defmodule WandererApp.Map.GarbageCollector do def cleanup_system_signatures() do Logger.info("Start cleanup old map system signatures...") - WandererApp.Api.MapSystemSignature - |> Ash.Query.filter(updated_at: [less_than: get_cutoff_time(@two_weeks_seconds)]) - |> Ash.bulk_destroy!(:destroy, %{}, batch_size: 100) + # Use return_errors? to handle stale records gracefully (race conditions with on-demand cleanup) + result = + WandererApp.Api.MapSystemSignature + |> Ash.Query.filter(updated_at: [less_than: get_cutoff_time(@two_weeks_seconds)]) + |> Ash.bulk_destroy(:destroy, %{}, batch_size: 100, return_errors?: true) + + case result do + %Ash.BulkResult{status: :error, errors: errors} -> + Logger.error("Failed to cleanup signatures: #{inspect(errors)}") - @logger.info(fn -> "All map system signatures processed" end) + %Ash.BulkResult{errors: errors} -> + report_bulk_errors(errors, "map system signatures") + end :ok end + # `Ash.bulk_destroy/4` returns a bare `%Ash.BulkResult{}`, never an `{:ok, _}` + # / `{:error, _}` tuple, so the previous tuple-matching `case` raised + # CaseClauseError on every run. `errors` is a list of `Ash.Error` structs (or + # nil when `return_errors?` is off), not `{record, error}` tuples. + defp report_bulk_errors(errors, label) when errors in [nil, []] do + @logger.info(fn -> "All #{label} processed successfully" end) + end + + defp report_bulk_errors(errors, label) do + non_stale_errors = + Enum.reject(errors, fn + %Ash.Error.Invalid{errors: [%Ash.Error.Changes.StaleRecord{}]} -> true + %Ash.Error.Changes.StaleRecord{} -> true + _ -> false + end) + + if non_stale_errors != [] do + Logger.warning("Some #{label} failed to delete: #{inspect(non_stale_errors)}") + end + + # Count the stale errors, not every error: `length(errors)` also counted the + # genuine failures logged above and then labelled them race conditions. + stale_count = length(errors) - length(non_stale_errors) + + @logger.info(fn -> "#{label} processed with #{stale_count} race conditions" end) + end + defp get_cutoff_time(seconds), do: DateTime.utc_now() |> DateTime.add(-seconds, :second) end diff --git a/lib/wanderer_app/map/map_routes.ex b/lib/wanderer_app/map/map_routes.ex index 258b075ae..2b7580682 100644 --- a/lib/wanderer_app/map/map_routes.ex +++ b/lib/wanderer_app/map/map_routes.ex @@ -19,17 +19,6 @@ defmodule WandererApp.Map.Routes do avoid: [] } - @minimum_route_attrs [ - :system_class, - :class_title, - :security, - :triglavian_invasion_status, - :solar_system_id, - :solar_system_name, - :region_name, - :is_shattered - ] - @get_link_pairs_advanced_params [ :include_mass_crit, :include_eol, @@ -43,34 +32,9 @@ defmodule WandererApp.Map.Routes do @logger Application.compile_env(:wanderer_app, :logger) def find(map_id, hubs, origin, routes_settings, false) do - do_find_routes( - map_id, - origin, - hubs, - routes_settings - ) - |> case do + case do_find_routes(map_id, origin, hubs, routes_settings) do {:ok, routes} -> - systems_static_data = - routes - |> Enum.map(fn route_info -> route_info.systems end) - |> List.flatten() - |> Enum.uniq() - |> Task.async_stream( - fn system_id -> - case WandererApp.CachedInfo.get_system_static_info(system_id) do - {:ok, nil} -> - nil - - {:ok, system} -> - system |> Map.take(@minimum_route_attrs) - end - end, - max_concurrency: System.schedulers_online() * 4 - ) - |> Enum.map(fn {:ok, val} -> val end) - - {:ok, %{routes: routes, systems_static_data: systems_static_data}} + {:ok, %{routes: routes, systems_static_data: hydrate_static_data(routes)}} _error -> {:ok, %{routes: [], systems_static_data: []}} @@ -90,7 +54,66 @@ defmodule WandererApp.Map.Routes do {:ok, %{routes: routes, systems_static_data: []}} end - defp do_find_routes(map_id, origin, hubs, routes_settings) do + @doc """ + Sibling of `find/5` for callers that must distinguish a solver outage from a + genuine no-path result (see the design doc, "Distinguishing failure from + no-route"). Same params assembly, same cache key, same TTL โ€” the only + difference is that a `get_routes_custom/3` error is returned to the caller + instead of falling back to the `get_routes_eve/4` stub. + + The final argument is named `hubs_limit_reached?`. It is *not* "avoid + wormholes": as in `find/5`, `true` means "the hub count already exceeded the + map's limit, skip the solver" โ€” see `find/5`'s second clause + (`map_routes.ex:80-91`) and its callers in `map_routes_event_handler.ex:96,105`. + Route alerts always pass `false`. + """ + @spec find_strict(binary(), [binary()], binary(), map(), boolean()) :: + {:ok, %{routes: [map()], systems_static_data: [map()]}} | {:error, term()} + def find_strict(map_id, hubs, origin, routes_settings, false) do + case do_find_routes(map_id, origin, hubs, routes_settings, strict: true) do + {:ok, routes} -> + # Unlike `find/5`, callers of `find_strict/5` (route alerts) must + # judge the ORIGIN's security too โ€” a highsec-only route from a + # highsec home is a different claim than one from a lowsec home. Pass + # `include_origin?: true` so the origin is present in + # `systems_static_data`; `find/5` is unaffected (default `false`). + {:ok, %{routes: routes, systems_static_data: hydrate_static_data(routes, true)}} + + {:error, _reason} = error -> + error + end + end + + def find_strict(_map_id, hubs, origin, _routes_settings, true) do + origin = origin |> String.to_integer() + hubs = hubs |> Enum.map(&(&1 |> String.to_integer())) + + routes = + hubs + |> Enum.map(fn hub -> + %{origin: origin, destination: hub, success: false, systems: [], has_connection: false} + end) + + {:ok, %{routes: routes, systems_static_data: []}} + end + + # `include_origin?` defaults to `false` to keep `find/5`'s observable output + # byte-identical to before this change. `find_strict/5` (route alerts) is + # the only caller that passes `true` โ€” see its own comment for why the + # origin's static data must be present for that caller and not this one. + defp hydrate_static_data(routes, include_origin? \\ false) do + routes + |> Enum.flat_map(fn route_info -> + if include_origin? do + [route_info.origin | route_info.systems] + else + route_info.systems + end + end) + |> WandererApp.Map.RouteStaticData.hydrate() + end + + defp do_find_routes(map_id, origin, hubs, routes_settings, opts \\ []) do origin = origin |> String.to_integer() hubs = hubs |> Enum.map(&(&1 |> String.to_integer())) @@ -208,19 +231,23 @@ defmodule WandererApp.Map.Routes do avoid: avoidance_list } - {:ok, all_routes} = get_all_routes(hubs, origin, params) + case get_all_routes(hubs, origin, params, opts) do + {:ok, all_routes} -> + routes = + all_routes + |> Enum.map(fn route_info -> + map_route_info(route_info) + end) + |> Enum.filter(fn route_info -> not is_nil(route_info) end) - routes = - all_routes - |> Enum.map(fn route_info -> - map_route_info(route_info) - end) - |> Enum.filter(fn route_info -> not is_nil(route_info) end) + {:ok, routes} - {:ok, routes} + {:error, _reason} = error -> + error + end end - defp get_all_routes(hubs, origin, params, opts \\ []) do + defp get_all_routes(hubs, origin, params, opts) do cache_key = "routes-#{origin}-#{hubs |> Enum.join("-")}-#{:crypto.hash(:sha, :erlang.term_to_binary(params))}" @@ -229,7 +256,7 @@ defmodule WandererApp.Map.Routes do {:ok, result} _ -> - case WandererApp.Esi.get_routes_custom(hubs, origin, params) do + case esi_client().get_routes_custom(hubs, origin, params) do {:ok, result} -> WandererApp.Cache.insert( cache_key, @@ -239,18 +266,24 @@ defmodule WandererApp.Map.Routes do {:ok, result} - {:error, _error} -> + {:error, error} -> error_file_path = save_error_params(origin, hubs, params) @logger.error( "Error getting custom routes for #{inspect(origin)}: #{inspect(params)}. Params saved to: #{error_file_path}" ) - WandererApp.Esi.get_routes_eve(hubs, origin, params, opts) + if Keyword.get(opts, :strict, false) do + {:error, error} + else + esi_client().get_routes_eve(hubs, origin, params, opts) + end end end end + defp esi_client, do: Application.get_env(:wanderer_app, :esi_client, WandererApp.Esi) + defp save_error_params(origin, hubs, params) do timestamp = DateTime.utc_now() |> DateTime.to_unix(:millisecond) filename = "#{timestamp}_route_error_params.json" diff --git a/lib/wanderer_app/map/map_server.ex b/lib/wanderer_app/map/map_server.ex index aec880dd7..5d9169d4e 100644 --- a/lib/wanderer_app/map/map_server.ex +++ b/lib/wanderer_app/map/map_server.ex @@ -30,7 +30,7 @@ defmodule WandererApp.Map.Server do end end - defdelegate untrack_characters(map_id, character_ids), to: Impl + defdelegate untrack_characters(map_id, character_ids, reason \\ :presence_driven), to: Impl defdelegate add_system(map_id, system_info, user_id, character_id, opts \\ []), to: Impl @@ -66,6 +66,10 @@ defmodule WandererApp.Map.Server do defdelegate remove_hub(map_id, hub_info), to: Impl + defdelegate update_system_owner(map_id, update), to: Impl + + defdelegate update_system_custom_flags(map_id, update), to: Impl + defdelegate add_ping(map_id, ping_info), to: Impl defdelegate cancel_ping(map_id, ping_info), to: Impl diff --git a/lib/wanderer_app/map/operations/connections.ex b/lib/wanderer_app/map/operations/connections.ex index e60bb9218..ad545517d 100644 --- a/lib/wanderer_app/map/operations/connections.ex +++ b/lib/wanderer_app/map/operations/connections.ex @@ -12,6 +12,11 @@ defmodule WandererApp.Map.Operations.Connections do # Connection type constants @connection_type_wormhole 0 + # 3, not 2: matches `ConnectionType.loop` in the TS enum + # (wormhole/gate/bridge/loop) and `@connection_type_loop` in + # map_server_connections_impl.ex. 2 is `bridge`, so loop connections were + # missing the wormhole ship-size rules below. + @connection_type_loop 3 # Ship size constants @small_ship_size 0 @@ -22,9 +27,7 @@ defmodule WandererApp.Map.Operations.Connections do # System class constants @c1_system_class 1 - @c4_system_class 4 @c13_system_class 13 - @ns_system_class 9 @doc """ Creates a connection between two systems, applying special rules for C1, C13, and C4 wormholes. @@ -116,7 +119,7 @@ defmodule WandererApp.Map.Operations.Connections do # If wormhole_type is provided (e.g., "H296"), infer ship size from it. defp resolve_ship_size(type_val, ship_size_val, wormhole_type, src_info, tgt_info) do case parse_type(type_val) do - @connection_type_wormhole -> + type when type in [@connection_type_wormhole, @connection_type_loop] -> wormhole_ship_size(ship_size_val, wormhole_type, src_info, tgt_info) _other -> diff --git a/lib/wanderer_app/map/operations/duplication.ex b/lib/wanderer_app/map/operations/duplication.ex index 308f0f2c3..7033005cb 100644 --- a/lib/wanderer_app/map/operations/duplication.ex +++ b/lib/wanderer_app/map/operations/duplication.ex @@ -94,28 +94,11 @@ defmodule WandererApp.Map.Operations.Duplication do # Copy a single system defp copy_single_system(source_system, new_map_id) do - # Get all attributes from the source system, excluding system-managed fields and metadata - excluded_fields = [ - # System managed fields - :id, - :inserted_at, - :updated_at, - :map_id, - :map, - # Ash/Ecto metadata fields - :__meta__, - :__lateral_join_source__, - :__metadata__, - :__order__, - :aggregates, - :calculations - ] - - # Convert the source system struct to a map and filter out excluded fields + # Same allowlist approach as connections -- see acceptable_attrs/3 below for + # why a denylist was the wrong shape here. system_attrs = source_system - |> Map.from_struct() - |> Map.drop(excluded_fields) + |> acceptable_attrs(MapSystem, :create) |> Map.put(:map_id, new_map_id) MapSystem.create(system_attrs) @@ -145,34 +128,41 @@ defmodule WandererApp.Map.Operations.Duplication do # Copy a single connection with updated system references defp copy_single_connection(source_connection, new_map_id, system_mapping) do - # Get all attributes from the source connection, excluding system-managed fields and metadata - excluded_fields = [ - # System managed fields - :id, - :inserted_at, - :updated_at, - :map_id, - :map, - # Ash/Ecto metadata fields - :__meta__, - :__lateral_join_source__, - :__metadata__, - :__order__, - :aggregates, - :calculations - ] - - # Convert the source connection struct to a map and filter out excluded fields connection_attrs = source_connection - |> Map.from_struct() - |> Map.drop(excluded_fields) + |> acceptable_attrs(MapConnection, :create) |> Map.put(:map_id, new_map_id) |> update_system_references(system_mapping) MapConnection.create(connection_attrs) end + # Build the attribute map from what the target action actually ACCEPTS, + # rather than by dropping a hand-maintained list of fields to exclude. + # + # This previously used `Map.from_struct() |> Map.drop(excluded_fields)`, a + # denylist: every attribute not explicitly named was forwarded to `create`. + # When commit 2f86fc9f added `locked_at` / `locked_by` / `locked_by_id` to + # MapConnection without adding them to the `:create` accept list, duplication + # started failing with Ash.Error.Invalid.NoSuchInput and every map + # duplication returned 500. + # + # An allowlist derived from the action cannot drift: a new attribute is + # copied only if the action accepts it, and is silently skipped otherwise. + defp acceptable_attrs(source, resource, action_name) do + accepted = + resource + |> Ash.Resource.Info.action(action_name) + |> Map.get(:accept, []) + |> MapSet.new() + + source + |> Map.from_struct() + |> Map.filter(fn {key, value} -> + MapSet.member?(accepted, key) and not match?(%Ash.NotLoaded{}, value) + end) + end + # Update system references in connection attributes using the system mapping defp update_system_references(connection_attrs, system_mapping) do connection_attrs @@ -202,28 +192,31 @@ defmodule WandererApp.Map.Operations.Duplication do Logger.debug("Copying signatures for map #{source_map.id}") # Get signatures by iterating through systems - source_signatures = get_all_map_signatures(source_map.id, system_mapping) - - Enum.reduce_while(source_signatures, {:ok, []}, fn source_signature, {:ok, acc_signatures} -> - case copy_single_signature(source_signature, new_map.id, system_mapping) do - {:ok, new_signature} -> - {:cont, {:ok, [new_signature | acc_signatures]}} - - {:error, reason} -> - {:halt, {:error, {:signature_copy_failed, reason}}} - end - end) + with {:ok, source_signatures} <- get_all_map_signatures(source_map.id, system_mapping) do + Enum.reduce_while(source_signatures, {:ok, []}, fn source_signature, + {:ok, acc_signatures} -> + case copy_single_signature(source_signature, new_map.id, system_mapping) do + {:ok, new_signature} -> + {:cont, {:ok, [new_signature | acc_signatures]}} + + {:error, reason} -> + {:halt, {:error, {:signature_copy_failed, reason}}} + end + end) + end end - # Get all signatures for a map by querying each system + # Get all signatures for a map by querying each system. A read failure for any + # system aborts the copy with an error rather than silently omitting those + # signatures, which would return an incomplete duplicate reported as success. defp get_all_map_signatures(_source_map_id, system_mapping) do # Get source system IDs and query signatures for each source_system_ids = Map.keys(system_mapping) - Enum.flat_map(source_system_ids, fn system_id -> - case MapSystemSignature.by_system_id_all(%{system_id: system_id}) do - {:ok, signatures} -> signatures - {:error, _} -> [] + Enum.reduce_while(source_system_ids, {:ok, []}, fn system_id, {:ok, acc} -> + case MapSystemSignature.by_system_id_all(system_id) do + {:ok, signatures} -> {:cont, {:ok, acc ++ signatures}} + {:error, reason} -> {:halt, {:error, {:signature_read_failed, reason}}} end end) end diff --git a/lib/wanderer_app/map/route_alert/evaluator.ex b/lib/wanderer_app/map/route_alert/evaluator.ex new file mode 100644 index 000000000..451eb2af5 --- /dev/null +++ b/lib/wanderer_app/map/route_alert/evaluator.ex @@ -0,0 +1,167 @@ +defmodule WandererApp.Map.RouteAlert.Evaluator do + @moduledoc """ + Pure decision function turning a `WandererApp.Map.Routes.find_strict/5` + result into the three-state model the route-alert watcher acts on. No HTTP, + no GenServer, no `CachedInfo` lookups โ€” every system's security and class + travel in `systems_static_data`, which `find_strict/5` already hydrates. + + See the design doc's "Alert semantics" and "Failure posture" sections for the + rules this module encodes. + """ + + alias WandererApp.SystemClass + + @jita_system_id 30_000_142 + @highsec_threshold 0.45 + + # Derived at compile time from the canonical list rather than restated, so + # this cannot drift from `SystemClass`. A module attribute is required because + # `system_qualifies?/2` matches on the class in a guard, and a guard cannot + # call a remote function. + @wormhole_classes SystemClass.wormhole_classes() + + # Pinned per the design's decision 8 โ€” there is no user in this code path, so + # settings are not read from any widget preference. `include_mass_crit: + # false` and `include_frig: false` differ from `Routes`' own module defaults + # because a crit or frigate-sized connection will not pass a hauler. + # `include_thera: false` keeps every alert attributable to the map's own + # chain rather than to public Thera connectivity. + @solver_settings %{ + include_eol: false, + include_mass_crit: false, + include_frig: false, + include_cruise: true, + avoid_pochven: true, + avoid_edencom: true, + avoid_triglavian: true, + include_thera: false + } + + @type outcome :: + {:qualifying, %{jumps: pos_integer(), path: [integer()], exit_system: integer() | nil}} + | :none + | :unknown + + @spec jita_system_id() :: 30_000_142 + def jita_system_id, do: @jita_system_id + + # `@spec ... :: 0.45` (as literally written in the design contract) is not + # valid Elixir: unlike integers, float literals cannot appear as singleton + # types in a typespec (`Kernel.Typespec.compile_error/2`). `float()` is the + # nearest valid spec; the value itself is still pinned to 0.45 below and + # asserted by `evaluate/2 โ€” the 0.45 boundary` and the constants test. + @spec highsec_threshold() :: float() + def highsec_threshold, do: @highsec_threshold + + @spec solver_settings() :: map() + def solver_settings, do: @solver_settings + + @doc """ + `opts` must include `max_jumps: pos_integer()`. + + Fails closed: any system on a route's path that is missing from + `systems_static_data`, or whose `security` will not parse, disqualifies that + route entirely (`:none`, not `:unknown`) โ€” an unresolvable system is a route + this module will not vouch for. See the design doc's "Failure posture". + """ + @spec evaluate({:ok, map()} | {:error, term()}, keyword()) :: outcome() + def evaluate({:error, _reason}, _opts), do: :unknown + + def evaluate({:ok, %{routes: []}}, _opts), do: :unknown + + def evaluate({:ok, %{routes: entries, systems_static_data: static_data}}, opts) do + if Enum.all?(entries, &unsuccessful?/1) do + :none + else + max_jumps = Keyword.fetch!(opts, :max_jumps) + static_by_id = index_static_data(static_data) + + entries + |> Enum.reject(&unsuccessful?/1) + |> Enum.find_value(:none, &qualify(&1, static_by_id, max_jumps)) + end + end + + defp unsuccessful?(%{success: false}), do: true + defp unsuccessful?(%{has_connection: false}), do: true + defp unsuccessful?(_entry), do: false + + defp qualify(entry, static_by_id, max_jumps) do + path = [entry.origin | entry.systems] + jumps = length(entry.systems) + + if jumps <= max_jumps and path_qualifies?(path, static_by_id) do + {:qualifying, + %{jumps: jumps, path: path, exit_system: find_exit_system(path, static_by_id)}} + end + end + + # `Enum.all?/2` short-circuits on the first disqualifying hop, which is also + # the fail-closed behavior: an unresolvable or wormhole-failing hop stops the + # check rather than being skipped. + defp path_qualifies?(path, static_by_id) do + Enum.all?(path, &system_qualifies?(&1, static_by_id)) + end + + defp system_qualifies?(system_id, static_by_id) do + case Map.fetch(static_by_id, system_id) do + :error -> + false + + {:ok, %{system_class: class}} when class in @wormhole_classes -> + true + + {:ok, %{security: security}} -> + case parse_security(security) do + {:ok, value} -> value >= @highsec_threshold + {:error, _reason} -> false + end + + # A static record carrying a non-wormhole class but no `:security` key at + # all. Fails closed for the same reason a missing record does: this module + # will not vouch for a system it cannot classify (design: "Failure + # posture"). Without this clause the `case` raises instead, which would + # take the whole solve down rather than disqualifying one route. + {:ok, _static} -> + false + end + end + + defp find_exit_system(path, static_by_id) do + Enum.find(path, fn system_id -> + case Map.fetch(static_by_id, system_id) do + {:ok, %{system_class: class}} -> not SystemClass.wormhole?(class) + :error -> false + end + end) + end + + defp index_static_data(static_data) do + static_data + |> Enum.reject(&is_nil/1) + |> Map.new(&{&1.solar_system_id, &1}) + end + + # Duplicated from `RouteBuilderClient.parse_security/1` (`route_builder_client.ex:200-210`) + # rather than reused: that function is private, and this module's threshold + # deliberately diverges from it (0.45 here vs. 0.5 there โ€” see + # `highsec_threshold/0`'s moduledoc reference and the design doc's decision + # 4), so sharing the parser without sharing the threshold would leave the one + # place that says "0.5" sitting next to the one place that says "0.45" with + # no visible link between them. + defp parse_security(security) when is_float(security), do: {:ok, security} + defp parse_security(security) when is_integer(security), do: {:ok, security * 1.0} + + defp parse_security(security) when is_binary(security) do + # Only a fully-consumed parse counts. `Float.parse("0.9invalid")` returns + # {0.9, "invalid"}, so accepting the remainder would read a corrupt static + # record as highsec โ€” failing OPEN on exactly the value this module exists + # to be careful about. + case Float.parse(String.trim(security)) do + {value, ""} -> {:ok, value} + _ -> {:error, :invalid_security} + end + end + + defp parse_security(_security), do: {:error, :invalid_security} +end diff --git a/lib/wanderer_app/map/route_static_data.ex b/lib/wanderer_app/map/route_static_data.ex new file mode 100644 index 000000000..836424742 --- /dev/null +++ b/lib/wanderer_app/map/route_static_data.ex @@ -0,0 +1,143 @@ +defmodule WandererApp.Map.RouteStaticData do + @moduledoc """ + Shared static-data hydration for route results. + + `Routes.find/5`, `Routes.find_strict/5` and `RoutesBy.find/3` each return a + `systems_static_data` list alongside their routes, and each hydrated it the + same way: one `CachedInfo.get_system_static_info/1` lookup per system, fanned + out with `Task.async_stream/3`. They were two near-identical private copies, + and the copies had already drifted โ€” `map_routes.ex` grew an `{:error, _}` + clause, `routes_by.ex` never did โ€” so the same defect had to be fixed twice + and was not. Centralizing here keeps the failure handling in one place. + """ + + require Logger + + @minimum_route_attrs [ + :system_class, + :class_title, + :security, + :triglavian_invasion_status, + :solar_system_id, + :solar_system_name, + :region_name, + :is_shattered + ] + + @default_static_info_timeout :timer.seconds(15) + + @logger Application.compile_env(:wanderer_app, :logger) + + @doc """ + Resolves `system_ids` to their minimal static attributes. + + Order follows `system_ids`, but the result is NOT positional: systems that + have no static record, whose lookup errors, or whose lookup overruns + `static_info_timeout/0` are omitted entirely. Callers look systems up by + `:solar_system_id`, never by index โ€” see `hydrate/1`'s body for why omission + beats a `nil` placeholder. + """ + @spec hydrate([integer()]) :: [map()] + def hydrate(system_ids) do + system_ids = Enum.uniq(system_ids) + lookup = lookup_fun() + + {static_data, timed_out_system_ids} = + system_ids + |> Task.async_stream( + fn system_id -> + case lookup.(system_id) do + {:ok, nil} -> + nil + + {:ok, system} -> + system |> Map.take(@minimum_route_attrs) + + # `get_system_static_info/1` also returns {:error, :not_found}, + # {:error, :api_error} and {:error, :cache_error}. Without this clause + # any of them raises CaseClauseError inside the task, and because + # `Task.async_stream` LINKS its tasks, that kills the calling process + # rather than surfacing as `{:exit, _}` below โ€” `on_timeout` only + # converts timeouts, never raises. + {:error, _reason} -> + nil + end + end, + max_concurrency: System.schedulers_online() * 4, + timeout: static_info_timeout(), + # `:kill_task` is what makes the `{:exit, _}` clause below reachable at + # all. Under the default `on_timeout: :exit` the overrunning task's exit + # travels the link and kills the CALLING process before the collector + # ever runs. The two options are a pair; neither is correct without the + # other. + # + # That mattered most for `Routes.find/5` and `RoutesBy.find/3`, which run + # inside plain `Task.async` calls linked to the LiveView + # (map_routes_event_handler.ex:98,142,192). LiveView does not trap exits, + # so one slow lookup remounted the user's entire map session. + # `Routes.find_strict/5` was already contained โ€” the route watcher calls + # it under `Task.Supervisor.async_nolink/2` (route_watcher.ex:257) and + # handles the `{:DOWN, ...}` โ€” but it still lost every system in the + # cycle where one system was slow. + on_timeout: :kill_task + ) + # `async_stream` is ordered by default, so zipping against the input + # recovers which system each result belongs to โ€” needed to report the + # timed-out ones, since `{:exit, _}` carries no system id. + |> Enum.zip(system_ids) + |> Enum.map_reduce([], fn + {{:ok, static_info}, _system_id}, timed_out -> {static_info, timed_out} + {{:exit, _reason}, system_id}, timed_out -> {nil, [system_id | timed_out]} + end) + + log_timed_out_systems(timed_out_system_ids) + + # Unresolved systems are dropped rather than passed through as `nil`. The + # frontend reads this list only by id โ€” `RoutesWidget.tsx:60` and + # `PingRoute.tsx:28` both do `.find(sd => sd.solar_system_id === id)` โ€” and a + # `null` element makes that predicate throw a TypeError, taking down the + # whole widget over one missing system. `Evaluator.index_static_data/1` + # (route alerts) already rejected nils on its own. + Enum.reject(static_data, &is_nil/1) + end + + defp log_timed_out_systems([]), do: :ok + + defp log_timed_out_systems(system_ids) do + # Worth a warning rather than silence: a dropped system is invisible in the + # UI โ€” `RoutesList.tsx:106` filters it out of the rendered chain while the + # jump count beside it still comes from the raw id list, so a timeout shows + # up to the user only as a route that looks shorter and safer than it is. + @logger.warning( + "Route static data lookup timed out for #{length(system_ids)} system(s): " <> + "#{inspect(Enum.reverse(system_ids))}. They are omitted from systems_static_data." + ) + end + + # Per-system budget for `hydrate/1`. Generous by default because a cold + # `get_system_static_info/1` scans the whole MapSolarSystem table and + # repopulates the cache row by row (cached_info.ex) โ€” killing that at + # `Task.async_stream`'s 5s default would drop static data routinely after any + # restart. Overridable so tests can force the timeout path. + defp static_info_timeout, + do: + Application.get_env( + :wanderer_app, + :route_static_info_timeout_ms, + @default_static_info_timeout + ) + + # Resolved once per `hydrate/1` rather than per system, and only overridden by + # tests: the timeout path is otherwise unreachable on demand, because a real + # `get_system_static_info/1` is either a microsecond cache hit or a full table + # scan whose duration nothing here controls. Squeezing the budget alone does + # not prove the fix โ€” that assertion passes whether or not a timeout actually + # fired. Blocking a named system does. + defp lookup_fun do + Application.get_env( + :wanderer_app, + :route_static_info_lookup, + &WandererApp.CachedInfo.get_system_static_info/1 + ) + end +end diff --git a/lib/wanderer_app/map/routes_by.ex b/lib/wanderer_app/map/routes_by.ex index 942ac47a6..6219342b0 100644 --- a/lib/wanderer_app/map/routes_by.ex +++ b/lib/wanderer_app/map/routes_by.ex @@ -5,17 +5,6 @@ defmodule WandererApp.Map.RoutesBy do require Logger - @minimum_route_attrs [ - :system_class, - :class_title, - :security, - :triglavian_invasion_status, - :solar_system_id, - :solar_system_name, - :region_name, - :is_shattered - ] - @default_routes_settings %{ path_type: "shortest", include_mass_crit: true, @@ -159,19 +148,8 @@ defmodule WandererApp.Map.RoutesBy do defp fetch_systems_static_data(routes) do routes - |> Enum.map(fn route_info -> route_info.systems end) - |> List.flatten() - |> Enum.uniq() - |> Task.async_stream( - fn system_id -> - case WandererApp.CachedInfo.get_system_static_info(system_id) do - {:ok, nil} -> nil - {:ok, system} -> system |> Map.take(@minimum_route_attrs) - end - end, - max_concurrency: System.schedulers_online() * 4 - ) - |> Enum.map(fn {:ok, val} -> val end) + |> Enum.flat_map(fn route_info -> route_info.systems end) + |> WandererApp.Map.RouteStaticData.hydrate() end defp build_avoidance_list(routes_settings) do diff --git a/lib/wanderer_app/map/server/map_server_characters_impl.ex b/lib/wanderer_app/map/server/map_server_characters_impl.ex index 4c77d9b7c..eabdf42ef 100644 --- a/lib/wanderer_app/map/server/map_server_characters_impl.ex +++ b/lib/wanderer_app/map/server/map_server_characters_impl.ex @@ -77,11 +77,25 @@ defmodule WandererApp.Map.Server.CharactersImpl do end) end - def untrack_characters(map_id, character_ids) do + @untrack_reasons [:presence_driven, :acl_revoked, :manual_untrack] + + @doc """ + Stops map tracking for the given characters. + + `reason` records which of the three branch points asked for this, and is + carried through to the `:stopped` telemetry event and into the tracker as + `last_cleared_reason`. It used to be hardcoded to `:presence_expired` here, + which labelled a deliberate operator untrack and an ACL revocation as + presence expiry โ€” a constant wearing a variable's name. Callers must pass one + of #{inspect(@untrack_reasons)}; anything else raises rather than silently + widening the label set the metrics are grouped by. + """ + def untrack_characters(map_id, character_ids, reason \\ :presence_driven) + when reason in @untrack_reasons do if length(character_ids) > 0 do Logger.debug(fn -> "[CharactersImpl] Untracking #{length(character_ids)} characters from map #{map_id} - " <> - "reason: characters no longer in presence_character_ids (grace period expired or user disconnected)" + "reason=#{reason}" end) end @@ -90,30 +104,31 @@ defmodule WandererApp.Map.Server.CharactersImpl do character_map_active = is_character_map_active?(map_id, character_id) character_map_active - |> untrack_character(map_id, character_id) + |> untrack_character(map_id, character_id, reason) end) end - defp untrack_character(true, map_id, character_id) do + defp untrack_character(true, map_id, character_id, reason) do Logger.info(fn -> "[CharactersImpl] Untracking character #{character_id} from map #{map_id} - " <> - "character was actively tracking this map" + "character was actively tracking this map, reason=#{reason}" end) # Emit telemetry for tracking :telemetry.execute( [:wanderer_app, :character, :tracking, :stopped], %{system_time: System.system_time()}, - %{character_id: character_id, map_id: map_id, reason: :presence_expired} + %{character_id: character_id, map_id: map_id, reason: reason} ) WandererApp.Character.TrackerManager.update_track_settings(character_id, %{ map_id: map_id, - track: false + track: false, + untrack_reason: reason }) end - defp untrack_character(false, map_id, character_id) do + defp untrack_character(false, map_id, character_id, _reason) do Logger.debug(fn -> "[CharactersImpl] Skipping untrack for character #{character_id} on map #{map_id} - " <> "character was not actively tracking this map" @@ -312,8 +327,9 @@ defmodule WandererApp.Map.Server.CharactersImpl do defp remove_and_untrack_characters(map_id, character_ids) do # Option 4: Enhanced logging for character removal - Logger.info(fn -> - "[CharacterCleanup] Map #{map_id} - starting removal of #{length(character_ids)} characters: #{inspect(character_ids)}" + Logger.warning(fn -> + "[CharacterCleanup] Map #{map_id} - permission-driven removal of #{length(character_ids)} characters: " <> + "#{inspect(character_ids)}, reason=acl_permission_revoked_3x" end) # Emit telemetry for monitoring @@ -324,7 +340,7 @@ defmodule WandererApp.Map.Server.CharactersImpl do ) map_id - |> untrack_characters(character_ids) + |> untrack_characters(character_ids, :acl_revoked) map_id |> WandererApp.MapCharacterSettingsRepo.get_by_map_filtered(character_ids) @@ -908,12 +924,18 @@ defmodule WandererApp.Map.Server.CharactersImpl do end defp update_location( - _state, - _character_id, - _location, + %{map_id: map_id} = _state, + character_id, + location, %{solar_system_id: nil} - ), - do: :ok + ) do + Logger.debug(fn -> + "[CharacterTracking] Skipped system add for character #{character_id} on map #{map_id}: " <> + "new_system=#{inspect(location.solar_system_id)}, reason=nil_old_solar_system_id" + end) + + :ok + end defp update_location( %{map: map, map_id: map_id, map_opts: map_opts} = diff --git a/lib/wanderer_app/map/server/map_server_connections_impl.ex b/lib/wanderer_app/map/server/map_server_connections_impl.ex index bc6f423ec..39f9c5345 100644 --- a/lib/wanderer_app/map/server/map_server_connections_impl.ex +++ b/lib/wanderer_app/map/server/map_server_connections_impl.ex @@ -19,13 +19,15 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do @ns 9 # @ccp2 10 # @ccp3 11 - @thera 12 + # Wormhole classes live in WandererApp.SystemClass; kept here commented as + # part of the class-id reference table. + # @thera 12 @c13 13 - @sentinel 14 - @barbican 15 - @vidette 16 - @conflux 17 - @redoubt 18 + # @sentinel 14 + # @barbican 15 + # @vidette 16 + # @conflux 17 + # @redoubt 18 @a1 19 @a2 20 @a3 21 @@ -40,21 +42,9 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do @jita 30_000_142 - @wh_space [ - @c1, - @c2, - @c3, - @c4, - @c5, - @c6, - @c13, - @thera, - @sentinel, - @barbican, - @vidette, - @conflux, - @redoubt - ] + # Derived, not restated: WandererApp.SystemClass owns the canonical list so + # the map server and server-side kill notifications cannot drift apart. + @wh_space WandererApp.SystemClass.wormhole_classes() @known_space [@hs, @ls, @ns, @pochven] @@ -107,7 +97,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do @connection_type_wormhole 0 @connection_type_stargate 1 - # @connection_type_bridge 2 # reserved for future use + @connection_type_loop 3 @medium_ship_size 1 def get_connection_auto_expire_hours(), do: WandererApp.Env.map_connection_auto_expire_hours() @@ -213,7 +203,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do solar_system_target_id ) |> case do - {:ok, %{id: connection_id} = connection} -> + {:ok, %{id: connection_id}} -> connection_mark_eol_time = get_connection_mark_eol_time(map_id, connection_id, nil) locked_info = get_connection_locked_info(map_id, connection_id) @@ -405,7 +395,7 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do ) not is_connection_exist || - (type == @connection_type_wormhole && + ((type == @connection_type_wormhole or type == @connection_type_loop) && time_status == @connection_time_status_eol && is_connection_valid( :wormholes, @@ -452,13 +442,18 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do end) end + # Loop connections age exactly like wormholes: the auto-delete filter below + # already treats `@connection_type_loop` as EOL-eligible, but matching only on + # `@connection_type_wormhole` here meant a loop never advanced to EOL in the + # first place, so it was never collected. defp maybe_update_connection_time_status(map_id, %{ id: connection_id, solar_system_source: solar_system_source_id, solar_system_target: solar_system_target_id, time_status: time_status, - type: @connection_type_wormhole - }) do + type: type + }) + when type in [@connection_type_wormhole, @connection_type_loop] do connection_start_time = get_start_time(map_id, connection_id) new_time_status = get_new_time_status(connection_start_time, time_status) @@ -654,8 +649,43 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do when not is_nil(location) and not is_nil(old_location) and not is_nil(old_location.solar_system_id) and location.solar_system_id != old_location.solar_system_id do - {:ok, character} = WandererApp.Character.get_character(character_id) + case WandererApp.Character.get_character(character_id) do + {:ok, character} -> + do_add_connection( + map_id, + location, + old_location, + character_id, + character, + is_manual, + extra_info + ) + + {:error, :not_found} -> + Logger.warning("[maybe_add_connection] Character #{character_id} not found") + {:error, :not_found} + end + end + def maybe_add_connection( + _map_id, + _location, + _old_location, + _character_id, + _is_manual, + _connection_extra_info + ), + do: :ok + + defp do_add_connection( + map_id, + location, + old_location, + character_id, + character, + is_manual, + extra_info + ) do if not is_manual do :telemetry.execute([:wanderer_app, :map, :character, :jump], %{count: 1}, %{}) @@ -725,7 +755,8 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do wormhole_type: wormhole_type }) - if connection_type == @connection_type_wormhole do + if connection_type == @connection_type_wormhole or + connection_type == @connection_type_loop do set_start_time(map_id, connection.id, DateTime.utc_now()) end @@ -781,16 +812,6 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do end end - def maybe_add_connection( - _map_id, - _location, - _old_location, - _character_id, - _is_manual, - _connection_extra_info - ), - do: :ok - defp get_extra_info(nil, _key, default_value), do: default_value defp get_extra_info(extra_info, key, default_value), do: Map.get(extra_info, key, default_value) diff --git a/lib/wanderer_app/map/server/map_server_impl.ex b/lib/wanderer_app/map/server/map_server_impl.ex index a173d87cf..2011773c1 100644 --- a/lib/wanderer_app/map/server/map_server_impl.ex +++ b/lib/wanderer_app/map/server/map_server_impl.ex @@ -219,7 +219,10 @@ defmodule WandererApp.Map.Server.Impl do defdelegate cleanup_systems(map_id), to: SystemsImpl defdelegate cleanup_connections(map_id), to: ConnectionsImpl defdelegate cleanup_characters(map_id), to: CharactersImpl - defdelegate untrack_characters(map_id, characters_ids), to: CharactersImpl + + defdelegate untrack_characters(map_id, characters_ids, reason \\ :presence_driven), + to: CharactersImpl + defdelegate add_system(map_id, system_info, user_id, character_id, opts \\ []), to: SystemsImpl defdelegate paste_connections(map_id, connections, user_id, character_id), to: ConnectionsImpl defdelegate paste_systems(map_id, systems, user_id, character_id, opts), to: SystemsImpl @@ -259,6 +262,10 @@ defmodule WandererApp.Map.Server.Impl do defdelegate update_connection_custom_info(map_id, connection_update), to: ConnectionsImpl defdelegate update_signatures(map_id, signatures_update), to: SignaturesImpl + defdelegate update_system_owner(map_id, update), to: SystemsImpl + + defdelegate update_system_custom_flags(map_id, update), to: SystemsImpl + def import_settings(map_id, settings, user_id) do WandererApp.Cache.put( "map_#{map_id}:importing", @@ -462,12 +469,13 @@ defmodule WandererApp.Map.Server.Impl do not WandererApp.Cache.lookup!("map_#{map_id}:importing", false) and WandererApp.Cache.lookup!("map_#{map_id}:started", false) - def get_update_map(update, attributes), - do: - {:ok, - Enum.reduce(attributes, Map.new(), fn attribute, map -> - map |> Map.put_new(attribute, get_in(update, [Access.key(attribute)])) - end)} + def get_update_map(update, attributes) do + {:ok, + Enum.reduce(attributes, Map.new(), fn attribute, map -> + value = get_in(update, [Access.key(attribute)]) + map |> Map.put_new(attribute, value) + end)} + end defp map_options(options) do [ @@ -549,17 +557,27 @@ defmodule WandererApp.Map.Server.Impl do character_id ) do systems - |> Enum.each(fn %{ - "description" => description, - "id" => id, - "labels" => labels, - "locked" => locked, - "name" => name, - "position" => %{"x" => x, "y" => y}, - "status" => status, - "tag" => tag, - "temporary_name" => temporary_name - } -> + |> Enum.each(fn system -> + # Extract required fields with defaults for optional ones + description = Map.get(system, "description", "") + id = Map.get(system, "id") + labels = Map.get(system, "labels", []) + locked = Map.get(system, "locked", false) + name = Map.get(system, "name", "") + position = Map.get(system, "position", %{"x" => 0, "y" => 0}) + x = Map.get(position, "x", 0) + y = Map.get(position, "y", 0) + status = Map.get(system, "status", 0) + tag = Map.get(system, "tag", "") + temporary_name = Map.get(system, "temporary_name", "") + owner_type = Map.get(system, "owner_type") + owner_id = Map.get(system, "owner_id") + # Exported by `get_export_settings`, so it has to be read back here too โ€” + # without it an imported system kept its owner id but lost the ticker the + # map actually renders. + owner_ticker = Map.get(system, "owner_ticker") + custom_flags = Map.get(system, "custom_flags") + solar_system_id = id |> String.to_integer() add_system( @@ -588,6 +606,22 @@ defmodule WandererApp.Map.Server.Impl do temporary_name: temporary_name }) + if owner_type || owner_id do + update_system_owner(map_id, %{ + solar_system_id: solar_system_id, + owner_type: owner_type, + owner_id: owner_id, + owner_ticker: owner_ticker + }) + end + + if custom_flags do + update_system_custom_flags(map_id, %{ + solar_system_id: solar_system_id, + custom_flags: custom_flags + }) + end + update_system_locked(map_id, %{solar_system_id: solar_system_id, locked: locked}) update_system_labels(map_id, %{solar_system_id: solar_system_id, labels: labels}) @@ -709,7 +743,7 @@ defmodule WandererApp.Map.Server.Impl do ) end - CharactersImpl.untrack_characters(map_id, not_present_character_ids) + CharactersImpl.untrack_characters(map_id, not_present_character_ids, :presence_driven) broadcast!( map_id, diff --git a/lib/wanderer_app/map/server/map_server_signatures_impl.ex b/lib/wanderer_app/map/server/map_server_signatures_impl.ex index 18e5f40c7..6d501053e 100644 --- a/lib/wanderer_app/map/server/map_server_signatures_impl.ex +++ b/lib/wanderer_app/map/server/map_server_signatures_impl.ex @@ -260,8 +260,23 @@ defmodule WandererApp.Map.Server.SignaturesImpl do end end - sig - |> MapSystemSignature.destroy!() + # Handle race conditions gracefully - signature may already be deleted + case Ash.destroy(sig) do + :ok -> + :ok + + {:ok, _} -> + :ok + + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.StaleRecord{}]}} -> + # Already deleted by another process - this is fine + :ok + + {:error, error} -> + require Logger + Logger.warning("Failed to delete signature #{sig.eve_id}: #{inspect(error)}") + {:error, error} + end end defp is_active_signature_for_target?(map_id, sig) do diff --git a/lib/wanderer_app/map/server/map_server_systems_impl.ex b/lib/wanderer_app/map/server/map_server_systems_impl.ex index 143ac6c57..e3e31b818 100644 --- a/lib/wanderer_app/map/server/map_server_systems_impl.ex +++ b/lib/wanderer_app/map/server/map_server_systems_impl.ex @@ -246,6 +246,43 @@ defmodule WandererApp.Map.Server.SystemsImpl do ), do: update_system(map_id, :update_custom_name, [:custom_name], update) + def update_system_owner(map_id, update) do + require Logger + + # Convert string keys to atoms if needed. All three owner fields, not just + # `owner_ticker`: the `Map.put_new/3` defaults below would otherwise overwrite + # a string-keyed `"owner_id"` / `"owner_type"` with nil and clear the owner. + update = + Enum.reduce([:owner_id, :owner_type, :owner_ticker], update, fn key, acc -> + string_key = Atom.to_string(key) + + case acc do + %{^key => _} -> acc + %{^string_key => value} -> acc |> Map.put(key, value) |> Map.delete(string_key) + _ -> acc + end + end) + + # Ensure all owner fields are present + update = + update + |> Map.put_new(:owner_id, nil) + |> Map.put_new(:owner_type, nil) + |> Map.put_new(:owner_ticker, nil) + + Logger.debug(fn -> "[update_system_owner] Updating with: #{inspect(update)}" end) + + map_id + |> update_system(:update_owner, [:owner_type, :owner_id, :owner_ticker], update) + end + + def update_system_custom_flags( + map_id, + update + ) do + map_id |> update_system(:update_custom_flags, [:custom_flags], update) + end + def update_system_locked( map_id, update @@ -346,6 +383,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do # For consistency, include basic fields even for deleted systems WandererApp.ExternalEvents.broadcast(map_id, :deleted_system, %{ + system_id: system_id, solar_system_id: solar_system_id, # System is deleted, name not available name: nil, @@ -633,6 +671,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do # ADDITIVE: Also broadcast to external event system (webhooks/WebSocket) WandererApp.ExternalEvents.broadcast(map_id, :add_system, %{ + system_id: updated_system.id, solar_system_id: updated_system.solar_system_id, name: updated_system.name, position_x: updated_system.position_x, @@ -650,6 +689,8 @@ defmodule WandererApp.Map.Server.SystemsImpl do } ) + maybe_sync_intel_from_source(map_id, updated_system) + :ok _ -> @@ -686,6 +727,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do # ADDITIVE: Also broadcast to external event system (webhooks/WebSocket) WandererApp.ExternalEvents.broadcast(map_id, :add_system, %{ + system_id: system.id, solar_system_id: system.solar_system_id, name: system.name, position_x: system.position_x, @@ -703,6 +745,8 @@ defmodule WandererApp.Map.Server.SystemsImpl do } ) + maybe_sync_intel_from_source(map_id, system) + :ok {:error, error} = result -> @@ -897,6 +941,7 @@ defmodule WandererApp.Map.Server.SystemsImpl do end) WandererApp.ExternalEvents.broadcast(map_id, :add_system, %{ + system_id: system.id, solar_system_id: system.solar_system_id, position_x: system.position_x, position_y: system.position_y @@ -904,6 +949,8 @@ defmodule WandererApp.Map.Server.SystemsImpl do track_add_system(map_id, user_id, character_id, system.solar_system_id) + maybe_sync_intel_from_source(map_id, system) + :ok {:error, reason} = error -> @@ -1069,19 +1116,20 @@ defmodule WandererApp.Map.Server.SystemsImpl do update, callback_fn \\ nil ) do + require Logger + with :ok <- WandererApp.Map.update_system_by_solar_system_id(map_id, update), {:ok, system} <- WandererApp.MapSystemRepo.get_by_map_and_solar_system_id( map_id, update.solar_system_id ), - {:ok, update_map} <- Impl.get_update_map(update, attributes) do - {:ok, updated_system} = - apply(WandererApp.MapSystemRepo, update_method, [ - system, - update_map - ]) - + {:ok, update_map} <- Impl.get_update_map(update, attributes), + {:ok, updated_system} <- + apply(WandererApp.MapSystemRepo, update_method, [ + system, + update_map + ]) do if not is_nil(callback_fn) do callback_fn.(updated_system) end @@ -1090,10 +1138,34 @@ defmodule WandererApp.Map.Server.SystemsImpl do else {:error, error} -> Logger.error("Failed to update system: #{inspect(error, pretty: true)}") + restore_system_cache(map_id, update, attributes) :ok error -> Logger.error("Failed to update system: #{inspect(error, pretty: true)}") + restore_system_cache(map_id, update, attributes) + :ok + end + end + + # The cache is written before the database, so a failed repository update left + # the in-memory map advertising a value that was never persisted โ€” and the + # `:ok` return hid it from the caller. Re-read the persisted row and put the + # affected attributes back so the cache converges on what is actually stored. + defp restore_system_cache(map_id, update, attributes) do + case WandererApp.MapSystemRepo.get_by_map_and_solar_system_id( + map_id, + update.solar_system_id + ) do + {:ok, system} -> + restore = + Enum.reduce(attributes, %{solar_system_id: update.solar_system_id}, fn attribute, acc -> + Map.put(acc, attribute, Map.get(system, attribute)) + end) + + WandererApp.Map.update_system_by_solar_system_id(map_id, restore) + + _error -> :ok end end @@ -1127,4 +1199,25 @@ defmodule WandererApp.Map.Server.SystemsImpl do :ok end + + defp maybe_sync_intel_from_source(map_id, system) do + with true <- WandererApp.Env.intel_sharing_enabled?(), + {:ok, %{map: %{intel_source_map_id: source_id}}} when not is_nil(source_id) <- + WandererApp.Map.get_map_state(map_id, false) do + Task.Supervisor.start_child(WandererApp.TaskSupervisor, fn -> + case WandererApp.Map.IntelSync.sync_system(map_id, source_id, system.solar_system_id) do + {:ok, updated_system} when is_map(updated_system) -> + intel_fields = WandererApp.Map.IntelSync.intel_fields() + update = Map.take(updated_system, [:solar_system_id | intel_fields]) + WandererApp.Map.update_system_by_solar_system_id(map_id, update) + update_map_system_last_activity(map_id, updated_system) + + _ -> + :ok + end + end) + else + _ -> :ok + end + end end diff --git a/lib/wanderer_app/map/signature_cleanup.ex b/lib/wanderer_app/map/signature_cleanup.ex new file mode 100644 index 000000000..b2448072f --- /dev/null +++ b/lib/wanderer_app/map/signature_cleanup.ex @@ -0,0 +1,156 @@ +defmodule WandererApp.Map.SignatureCleanup do + @moduledoc """ + On-demand signature cleanup for expired signatures. + Runs per-system when signatures are viewed or updated. + """ + + require Logger + + @doc """ + Cleans up expired signatures for a system asynchronously. + Returns :ok immediately without blocking the caller. + """ + def cleanup_async(system_id) do + Task.Supervisor.start_child(WandererApp.TaskSupervisor, fn -> cleanup(system_id) end) + :ok + end + + @doc """ + Cleans up expired signatures for a system synchronously. + """ + def cleanup(system_id) do + case WandererApp.Api.MapSystem.by_id(system_id) do + {:ok, system} -> + do_cleanup(system) + + {:error, reason} -> + Logger.warning("Signature cleanup: system #{system_id} not found: #{inspect(reason)}") + :ok + end + end + + defp do_cleanup(system) do + map_id = system.map_id + + wormhole_expiration_hours = + Application.get_env(:wanderer_app, :signatures)[:wormhole_expiration_hours] || 24 + + default_expiration_hours = + Application.get_env(:wanderer_app, :signatures)[:default_expiration_hours] || 72 + + # Not `|| true`: `false || true` is `true`, so an explicit + # `preserve_connected: false` was silently ignored and the setting could + # only ever be on. + preserve_connected = + case Application.get_env(:wanderer_app, :signatures)[:preserve_connected] do + nil -> true + value -> value + end + + max_age_hours = + Application.get_env(:wanderer_app, :signature_cleanup)[:max_age_hours] || 24 + + signatures = WandererApp.Api.MapSystemSignature.by_system_id!(system.id) + + wormhole_cutoff = + if wormhole_expiration_hours > 0, + do: DateTime.utc_now() |> DateTime.add(-wormhole_expiration_hours, :hour), + else: nil + + default_cutoff = + if default_expiration_hours > 0, + do: DateTime.utc_now() |> DateTime.add(-default_expiration_hours, :hour), + else: nil + + old_cutoff = DateTime.utc_now() |> DateTime.add(-max_age_hours, :hour) + + if wormhole_expiration_hours == 0 && default_expiration_hours == 0 do + Logger.debug("Signature expiration is disabled via environment variables") + # The very-old sweep runs ONLY here. It is the backstop for the + # expiration-disabled case; running it alongside the per-group windows + # made a 24h `max_age_hours` silently override a longer configured + # `default_expiration_hours` (72h by default). + cleanup_very_old_signatures(signatures, old_cutoff, preserve_connected, system, map_id) + else + expired_signatures = + signatures + |> Enum.filter(fn sig -> + if preserve_connected && not is_nil(sig.linked_system_id) do + false + else + cutoff = if sig.group == "Wormhole", do: wormhole_cutoff, else: default_cutoff + not is_nil(cutoff) && DateTime.compare(sig.updated_at, cutoff) == :lt + end + end) + + process_expired_signatures(expired_signatures, system, map_id) + end + end + + defp cleanup_very_old_signatures(signatures, old_cutoff, preserve_connected, system, map_id) do + very_old_signatures = + signatures + |> Enum.filter(fn sig -> + if preserve_connected && not is_nil(sig.linked_system_id) do + false + else + DateTime.compare(sig.updated_at, old_cutoff) == :lt + end + end) + + process_expired_signatures(very_old_signatures, system, map_id) + end + + defp process_expired_signatures(expired_signatures, system, map_id) do + if not Enum.empty?(expired_signatures) do + count = length(expired_signatures) + + Logger.info("Cleaning up #{count} expired signatures from system #{system.solar_system_id}") + + expired_signatures + |> Enum.each(fn sig -> + if not is_nil(sig.linked_system_id) do + map_id + |> WandererApp.Map.Server.update_system_linked_sig_eve_id(%{ + solar_system_id: sig.linked_system_id, + linked_sig_eve_id: nil + }) + end + + case WandererApp.Api.MapSystemSignature.destroy(sig) do + :ok -> + Logger.debug( + "Deleted expired signature #{sig.eve_id} from system #{system.solar_system_id}" + ) + + {:ok, _} -> + Logger.debug( + "Deleted expired signature #{sig.eve_id} from system #{system.solar_system_id}" + ) + + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.StaleRecord{}]}} -> + Logger.debug("Signature #{sig.eve_id} already deleted by another process") + + {:error, error} -> + Logger.warning("Failed to delete signature #{sig.eve_id}: #{inspect(error)}") + end + end) + + :telemetry.execute( + [:wanderer_app, :signature_cleanup, :completed], + %{count: count}, + %{ + system_id: system.id, + solar_system_id: system.solar_system_id, + map_id: map_id, + trigger: :on_demand + } + ) + + Phoenix.PubSub.broadcast!(WandererApp.PubSub, map_id, %{ + event: :signatures_updated, + payload: system.solar_system_id + }) + end + end +end diff --git a/lib/wanderer_app/maps.ex b/lib/wanderer_app/maps.ex index 56805288e..feea722cb 100644 --- a/lib/wanderer_app/maps.ex +++ b/lib/wanderer_app/maps.ex @@ -362,4 +362,31 @@ defmodule WandererApp.Maps do {:error, error} end end + + @doc """ + Returns the user's effective role for a map (:admin, :manager, :member, :viewer, or nil). + Used for intel source map eligibility checks. + """ + def get_user_role_for_map(map, current_user) do + with {:ok, loaded_map} <- Ash.load(map, :user_permissions, actor: current_user) do + character_ids = Enum.map(current_user.characters, & &1.id) + + permissions = + WandererApp.Permissions.get_map_permissions( + loaded_map.user_permissions, + loaded_map.owner_id, + character_ids + ) + + cond do + permissions.admin_map -> :admin + permissions.manage_map -> :manager + permissions.add_system -> :member + permissions.view_system -> :viewer + true -> nil + end + else + _ -> nil + end + end end diff --git a/lib/wanderer_app/market/triff.ex b/lib/wanderer_app/market/triff.ex new file mode 100644 index 000000000..d9b18627c --- /dev/null +++ b/lib/wanderer_app/market/triff.ex @@ -0,0 +1,219 @@ +defmodule WandererApp.Market.Triff do + @moduledoc """ + Bulk item pricing via [triff.tools](https://triff.tools). + + Used by the Discord notable-items enricher to decide which dropped loot is + worth naming. triff needs no API token, which is why this feature is not + disabled-by-default the way wanderer-notifier's Janice-backed equivalent is. + + ## Scope and mode + + Prices are quoted against **Jita 4-4** (`station_id=60003760`), fixed. It is + the tightest market in EVE and what players mentally price loot against. The + `station_id` (or `region_id`) parameter is **mandatory** โ€” without it the API + answers `400 {"error":"station_id or region_id required"}`. + + The selected price is the 5th-percentile sell order, falling back to the best + sell order when `p05` is null. Thin markets have too few orders to support a + percentile; a null `best` has nothing to fall back to, so that type is omitted + from the result map rather than coerced to zero. + + ## Caching + + Three distinct entries in `:api_cache`, all short-lived because prices move: + + * a resolved price per type id (30 min); + * a `:no_quote` sentinel per type id (10 min) for types with no usable order + on either side. Without an explicit sentinel these would be re-requested on + every kill that drops them, forever โ€” precisely the permanently-unpriced + long tail; + * one whole-request failure marker (60 s), so a hard-down triff costs one + round trip per minute rather than one per batch. + """ + + require Logger + + alias WandererApp.Market.Triff.HttpClient + + @quote_url "https://triff.tools/api/market/quote" + @jita_4_4_station_id 60_003_760 + + # Matches authGD's QUOTE_CHUNK. Larger chunks risk a URL length rejection. + @chunk_size 900 + + @price_ttl :timer.minutes(30) + @no_quote_ttl :timer.minutes(10) + @failure_ttl :timer.seconds(60) + + # The priciest item in EVE is on the order of 1e12 ISK, so anything past this + # is a malformed response, not a real quote. Mirrors authGD's sideSchema. + @max_price 1.0e15 + + @failure_key "triff-request-failure" + + @type prices :: %{integer() => float()} + + @doc """ + Quotes the given type ids. + + Returns `{:ok, prices}` where `prices` maps type id to unit price in ISK. + Types with no usable quote are **absent from the map** โ€” callers must not + treat a missing key as zero. + + Returns `{:error, reason}` if the very first request in the batch fails, or if + a recent request already failed and the cooldown is still active. A batch that + spans several chunks and fails partway returns `{:ok, partial}` with whatever + was priced before the failure โ€” an under-reported section beats no section, + and unpriced types are already an expected outcome. + """ + @spec quote_types([integer()]) :: {:ok, prices()} | {:error, term()} + def quote_types(type_ids) do + ids = type_ids |> Enum.filter(&is_integer/1) |> Enum.uniq() + {cached, missing} = split_cached(ids) + + cond do + missing == [] -> + {:ok, cached} + + recently_failed?() -> + {:error, :recent_failure} + + true -> + fetch_missing(missing, cached) + end + end + + # Halts on the first failing chunk โ€” a failure usually means triff is down, so + # issuing the remaining chunks would just add round trips to a dead endpoint. + # But the chunks that already answered are kept and returned: their prices are + # real, and discarding them would cost the whole batch its section over a + # partial outage. The failure is still marked, so the cooldown engages either + # way. + defp fetch_missing(missing, cached) do + missing + |> Enum.chunk_every(@chunk_size) + |> Enum.reduce_while({cached, 0, nil}, fn chunk, {acc, ok_count, _reason} -> + case fetch_chunk(chunk) do + {:ok, priced} -> {:cont, {Map.merge(acc, priced), ok_count + 1, nil}} + {:error, reason} -> {:halt, {acc, ok_count, reason}} + end + end) + |> case do + {prices, _ok_count, nil} -> + {:ok, prices} + + {_prices, 0, reason} -> + mark_failure(reason) + {:error, reason} + + {prices, ok_count, reason} -> + mark_failure(reason) + + Logger.warning( + "[Triff] returning #{map_size(prices)} prices from #{ok_count} " <> + "chunk(s) after a later chunk failed" + ) + + {:ok, prices} + end + end + + defp fetch_chunk(ids) do + with {:ok, 200, body} <- HttpClient.get(url(ids), [{"accept", "application/json"}]), + {:ok, priced} <- parse(body) do + cache_results(ids, priced) + {:ok, Map.take(priced, ids)} + else + {:ok, status, _body} -> {:error, {:http_status, status}} + {:error, reason} -> {:error, reason} + end + end + + defp url(ids) do + query = + URI.encode_query( + type_ids: Enum.join(ids, ","), + include_aggregates: "true", + include_orders: "false", + station_id: @jita_4_4_station_id + ) + + @quote_url <> "?" <> query + end + + defp parse(body) do + case Jason.decode(body) do + {:ok, %{"types" => types}} when is_list(types) -> + {:ok, Enum.reduce(types, %{}, &collect_price/2)} + + {:ok, _other} -> + {:error, :malformed_response} + + {:error, reason} -> + {:error, {:invalid_json, reason}} + end + end + + defp collect_price(%{"type_id" => id} = type, acc) when is_integer(id) do + case select_price(Map.get(type, "sell")) do + nil -> acc + price -> Map.put(acc, id, price) + end + end + + defp collect_price(_type, acc), do: acc + + # p05 first, best as the fallback. An invalid p05 is treated like a null one: + # either way we have no usable percentile and the best order is the next-best + # answer. + defp select_price(%{} = sell) do + valid_price(Map.get(sell, "p05")) || valid_price(Map.get(sell, "best")) + end + + defp select_price(_), do: nil + + # `Jason` cannot produce NaN or Infinity โ€” they are not valid JSON โ€” so the + # range check is the whole non-finite guard. + defp valid_price(n) when is_number(n) and n > 0 and n <= @max_price, do: n * 1.0 + defp valid_price(_), do: nil + + defp split_cached(ids) do + {found, missing} = + Enum.reduce(ids, {%{}, []}, fn id, {found, missing} -> + case Cachex.get(:api_cache, cache_key(id)) do + {:ok, price} when is_float(price) -> {Map.put(found, id, price), missing} + # A known-unpriced type: neither a hit to return nor a miss to re-request. + {:ok, :no_quote} -> {found, missing} + _ -> {found, [id | missing]} + end + end) + + {found, Enum.reverse(missing)} + end + + # Every requested id gets an entry, including ids the response omitted + # entirely โ€” otherwise the unpriced long tail is re-requested forever. + defp cache_results(requested_ids, priced) do + Enum.each(requested_ids, fn id -> + case Map.get(priced, id) do + nil -> Cachex.put(:api_cache, cache_key(id), :no_quote, ttl: @no_quote_ttl) + price -> Cachex.put(:api_cache, cache_key(id), price, ttl: @price_ttl) + end + end) + end + + defp cache_key(id), do: "triff-price-#{id}" + + defp recently_failed? do + match?({:ok, true}, Cachex.get(:api_cache, @failure_key)) + end + + defp mark_failure(reason) do + Logger.warning( + "[Triff] quote request failed (#{inspect(reason)}); " <> + "suppressing requests for #{div(@failure_ttl, 1000)}s" + ) + + Cachex.put(:api_cache, @failure_key, true, ttl: @failure_ttl) + end +end diff --git a/lib/wanderer_app/market/triff/http_client.ex b/lib/wanderer_app/market/triff/http_client.ex new file mode 100644 index 000000000..353917661 --- /dev/null +++ b/lib/wanderer_app/market/triff/http_client.ex @@ -0,0 +1,50 @@ +defmodule WandererApp.Market.Triff.HttpClient do + @moduledoc """ + Seam over HTTP calls to triff.tools, so market pricing can be tested without a + live endpoint. The real implementation uses an isolated Finch pool. + + Mirrors `WandererApp.ExternalEvents.Discord.HttpClient`. + """ + + @callback get(url :: String.t(), headers :: list()) :: + {:ok, status :: integer(), body :: binary()} | {:error, term()} + + @doc "Returns the configured implementation module." + def impl do + Application.get_env( + :wanderer_app, + :triff_http_client, + WandererApp.Market.Triff.HttpClient.Live + ) + end + + @doc "Issues a GET, delegating to the configured implementation." + def get(url, headers \\ []), do: impl().get(url, headers) + + defmodule Live do + @moduledoc """ + Real HTTP delivery via the isolated triff Finch pool. + + Named `Live` rather than `Finch` so the nested module does not shadow the + Finch library inside its own body. + """ + @behaviour WandererApp.Market.Triff.HttpClient + + # A backstop, not the real deadline. In the dispatcher path the enrichment + # task is brutally killed at `notable_items_timeout_ms` (1.5s by default), + # well before this fires; this only bounds a caller running outside that + # budget, so a hung socket cannot hold a pool connection indefinitely. + @timeout 5_000 + + @impl true + def get(url, headers) do + :get + |> Finch.build(url, headers) + |> Finch.request(WandererApp.Finch.Triff, receive_timeout: @timeout) + |> case do + {:ok, %Finch.Response{status: status, body: body}} -> {:ok, status, body} + {:error, reason} -> {:error, reason} + end + end + end +end diff --git a/lib/wanderer_app/metrics/prom_ex_plugin.ex b/lib/wanderer_app/metrics/prom_ex_plugin.ex index 065c0995e..ff7048ad2 100644 --- a/lib/wanderer_app/metrics/prom_ex_plugin.ex +++ b/lib/wanderer_app/metrics/prom_ex_plugin.ex @@ -17,6 +17,25 @@ defmodule WandererApp.Metrics.PromExPlugin do @map_subscription_cancel_event [:wanderer_app, :map, :subscription, :cancel] @map_subscription_expired_event [:wanderer_app, :map, :subscription, :expired] + # Location-tracking defect instrumentation. These three counters are read + # together to validate the maybe_start_location_tracking/2 fix: + # + # cleared - the (is_online: true, track_location: false) pair was created + # repaired - a character who WOULD have frozen was restored by the fix + # skipped - a character is frozen right now; must stay at zero post-fix + # + # repaired > 0 with skipped == 0 confirms both the mechanism and the fix. + # skipped > 0 means a route to the frozen state the fix does not cover. + @location_flag_cleared_event [:wanderer_app, :character, :tracking, :location_flag_cleared] + @location_flag_repaired_event [:wanderer_app, :character, :tracking, :location_flag_repaired] + @tracking_stopped_event [:wanderer_app, :character, :tracking, :stopped] + @location_skipped_while_active_event [ + :wanderer_app, + :character, + :tracking, + :location_skipped_while_active + ] + # ESI-related events @esi_rate_limited_event [:wanderer_app, :esi, :rate_limited] @esi_error_event [:wanderer_app, :esi, :error] @@ -32,7 +51,11 @@ defmodule WandererApp.Metrics.PromExPlugin do base_metrics = [ user_event_metrics(), map_event_metrics(), - map_subscription_metrics() + map_subscription_metrics(), + # Registered as a base metric on purpose: this instrumentation exists to + # catch a rare, hard-to-reproduce defect, so it must not be switched off + # by WANDERER_BASE_METRICS_ONLY. Three counters, no tags โ€” negligible cost. + location_tracking_defect_metrics() ] advanced_metrics = [ @@ -49,6 +72,54 @@ defmodule WandererApp.Metrics.PromExPlugin do end end + defp location_tracking_defect_metrics do + Event.build( + :wanderer_app_location_tracking_defect_metrics, + [ + counter( + @location_flag_cleared_event ++ [:count], + event_name: @location_flag_cleared_event, + description: + "Times location tracking was cleared while the character was still online in EVE, " <> + "by which branch point asked for the untrack", + tags: [:reason], + tag_values: &get_untrack_reason_tag_values/1 + ), + counter( + @location_flag_repaired_event ++ [:count], + event_name: @location_flag_repaired_event, + description: + "Times an online character's location tracking was restored on map re-entry, " <> + "tagged with the reason for the clear being undone; only reason=presence_driven " <> + "counts as the fix saving a character that would otherwise have frozen", + tags: [:reason], + tag_values: &get_untrack_reason_tag_values/1 + ), + # Emitted on every map untrack since long before this plugin existed and + # collected nowhere until now. It is the denominator the two counters + # above are read against: cleared/stopped is the share of untracks that + # caught a character still online in EVE. + counter( + @tracking_stopped_event ++ [:count], + event_name: @tracking_stopped_event, + description: + "Times map tracking was stopped for a character, by which branch point asked", + tags: [:reason], + tag_values: &get_untrack_reason_tag_values/1 + ), + counter( + @location_skipped_while_active_event ++ [:count], + event_name: @location_skipped_while_active_event, + description: + "Character-minutes during which an online, map-active character had location " <> + "tracking disabled; expected to be zero", + tags: [], + tag_values: &get_empty_tag_values/1 + ) + ] + ) + end + defp user_event_metrics do Event.build( :wanderer_app_user_event_metrics, @@ -245,6 +316,19 @@ defmodule WandererApp.Metrics.PromExPlugin do %{} end + # Bounded on purpose: the emitters only ever send one of the three branch + # reasons or :unknown, and anything else collapses to :unknown rather than + # opening an unbounded label dimension on a counter Prometheus keeps forever. + @untrack_reasons [:presence_driven, :acl_revoked, :manual_untrack, :unknown] + + defp get_untrack_reason_tag_values(%{reason: reason}) when reason in @untrack_reasons do + %{reason: reason} + end + + defp get_untrack_reason_tag_values(_metadata) do + %{reason: :unknown} + end + defp json_api_metrics do Event.build( :wanderer_app_json_api_metrics, diff --git a/lib/wanderer_app/repositories/map_repo.ex b/lib/wanderer_app/repositories/map_repo.ex index 9a06da847..0121cc6fe 100644 --- a/lib/wanderer_app/repositories/map_repo.ex +++ b/lib/wanderer_app/repositories/map_repo.ex @@ -195,4 +195,8 @@ defmodule WandererApp.MapRepo do {:ok, data} = options_to_form_data(options) data end + + def set_intel_source_map(map, source_map_id) do + WandererApp.Api.Map.set_intel_source_map(map, %{intel_source_map_id: source_map_id}) + end end diff --git a/lib/wanderer_app/repositories/map_system_repo.ex b/lib/wanderer_app/repositories/map_system_repo.ex index e8d229309..d6c44e6b2 100644 --- a/lib/wanderer_app/repositories/map_system_repo.ex +++ b/lib/wanderer_app/repositories/map_system_repo.ex @@ -160,6 +160,28 @@ defmodule WandererApp.MapSystemRepo do |> WandererApp.Api.MapSystem.update_custom_name(update) end + def update_owner(system, update) do + # Convert empty strings to nil for owner_ticker + ticker = + case Map.get(update, :owner_ticker) do + "" -> nil + ticker -> ticker + end + + clean_update = %{ + owner_id: Map.get(update, :owner_id), + owner_type: Map.get(update, :owner_type), + owner_ticker: ticker + } + + WandererApp.Api.MapSystem.update_owner(system, clean_update) + end + + def update_custom_flags(system, update) do + system + |> WandererApp.Api.MapSystem.update_custom_flags(update) + end + def update_labels(system, update), do: system diff --git a/lib/wanderer_app/repositories/map_user_settings_repo.ex b/lib/wanderer_app/repositories/map_user_settings_repo.ex index babbec427..099e5ea28 100644 --- a/lib/wanderer_app/repositories/map_user_settings_repo.ex +++ b/lib/wanderer_app/repositories/map_user_settings_repo.ex @@ -1,6 +1,8 @@ defmodule WandererApp.MapUserSettingsRepo do use WandererApp, :repository + require Logger + @default_form_data %{ "select_on_spash" => false, "link_signature_on_splash" => false, @@ -97,4 +99,122 @@ defmodule WandererApp.MapUserSettingsRepo do def to_boolean(value) when is_binary(value), do: value |> String.to_existing_atom() def to_boolean(value) when is_boolean(value), do: value + + @doc """ + Gets all map user settings for a given map_id. + Returns {:ok, [settings]} or {:error, reason} + """ + def get_by_map(map_id) when is_binary(map_id) and map_id != "" do + try do + # `read_by_map!/1` returns a list or raises; the former `nil` and + # unexpected-result branches were unreachable. + {:ok, WandererApp.Api.MapUserSettings.read_by_map!(%{map_id: map_id})} + rescue + error -> + Logger.error("Database error in get_by_map: #{inspect(error)}") + {:error, error} + end + end + + @doc """ + The de-duplicated set of `ready_characters` eve ids across every user of a map. + + Cached, because this is read on the hot path. Every `characters_updated` + broadcast is enriched with a `:ready` flag by each connected LiveView + independently (`MapCharactersEventHandler.map_ui_characters_with_ready/2`), + so on a map with N viewers one broadcast used to mean N identical + `map_user_settings_v1` reads returning the same rows. + + Invalidated by `Api.MapUserSettings`'s `:update_ready_characters` action + rather than by its callers, so a new write path cannot forget to. + The TTL is a backstop for anything that ever writes the column outside that + action, not the primary freshness mechanism. + """ + @ready_ids_ttl :timer.minutes(5) + + def ready_character_eve_ids(map_id) when is_binary(map_id) and map_id != "" do + case WandererApp.Cache.lookup!(ready_ids_cache_key(map_id)) do + nil -> + # A failed read yields `[]` for this call but is deliberately not + # cached: caching it would pin every viewer's ready flags off for the + # full TTL because of one transient database error. + case load_ready_character_eve_ids(map_id) do + {:ok, ids} -> + WandererApp.Cache.insert(ready_ids_cache_key(map_id), ids, ttl: @ready_ids_ttl) + ids + + :error -> + [] + end + + ids -> + ids + end + end + + def ready_character_eve_ids(_map_id), do: [] + + @doc """ + Drops the cached ready-character set for a map. Safe to call for a map that + was never cached. + """ + def invalidate_ready_character_eve_ids(map_id) when is_binary(map_id) and map_id != "" do + WandererApp.Cache.delete(ready_ids_cache_key(map_id)) + :ok + end + + def invalidate_ready_character_eve_ids(_map_id), do: :ok + + defp ready_ids_cache_key(map_id), do: "map:#{map_id}:ready_character_eve_ids" + + defp load_ready_character_eve_ids(map_id) do + case get_by_map(map_id) do + {:ok, settings_list} -> + {:ok, + settings_list + |> Enum.flat_map(fn setting -> setting.ready_characters || [] end) + |> Enum.uniq()} + + {:error, _reason} -> + :error + end + end + + @doc """ + Gets all map user settings where the specified character_eve_id is marked as ready. + Returns {:ok, [settings]} or {:error, reason} + """ + def get_settings_with_ready_character(character_eve_id) + when is_binary(character_eve_id) and character_eve_id != "" do + # Ash action rather than the raw Ecto query this used to run: that query + # rebuilt partial `%MapUserSettings{}` structs by hand, so every field it + # forgot to select silently came back as the struct default. + case WandererApp.Api.MapUserSettings.read_by_ready_character(%{ + character_eve_id: character_eve_id + }) do + {:ok, settings_list} -> + {:ok, settings_list} + + {:error, reason} -> + Logger.error("Failed to read settings by ready character: #{inspect(reason)}") + {:error, reason} + end + end + + def get_settings_with_ready_character(character_eve_id) do + Logger.warning( + "Invalid character_eve_id provided (#{inspect(typeof(character_eve_id))}): " <> + "expected a non-empty binary" + ) + + {:error, :invalid_character_eve_id} + end + + defp typeof(value) when is_binary(value), do: :binary + defp typeof(value) when is_nil(value), do: nil + defp typeof(value) when is_atom(value), do: :atom + defp typeof(value) when is_integer(value), do: :integer + defp typeof(value) when is_list(value), do: :list + defp typeof(value) when is_map(value), do: :map + defp typeof(_value), do: :other end diff --git a/lib/wanderer_app/route_builder_client.ex b/lib/wanderer_app/route_builder_client.ex index 738b6afdf..037581b9e 100644 --- a/lib/wanderer_app/route_builder_client.ex +++ b/lib/wanderer_app/route_builder_client.ex @@ -6,6 +6,28 @@ defmodule WandererApp.RouteBuilderClient do require Logger @timeout_opts [pool_timeout: 5_000, receive_timeout: :timer.seconds(30)] + + # Mint resolves IPv4-only unless told otherwise, but on Fly the route builder + # is reachable only over the IPv6 6PN network โ€” `route-builder.internal` has + # no A record at all, so every request fails with :nxdomain. Callers see that + # as an ordinary route lookup failure, and the ESI fallback path then reports + # "no connection" for every hub, so nothing in the logs names DNS. + # + # `inet6: true` keeps Mint's `inet4: true` default, which means it tries IPv6 + # first and falls back to IPv4. Deployments where the route builder is an + # IPv4 docker-compose service (CUSTOM_ROUTE_BASE_URL=http://eve-route-builder:2001) + # keep working, at the cost of one failed resolution per new connection. + # That fallback is why this is unconditional rather than an operator flag: a + # flag left unset on Fly fails silently, which is the bug being fixed here. + @connect_opts [connect_options: [transport_opts: [inet6: true]]] + + @doc """ + Req options every caller of the route builder service must pass. + + Shared with `WandererApp.Esi.ApiClient`, which posts to `/route/multiple` on + the same service. + """ + def connect_opts, do: @connect_opts @loot_dir Path.join(["repo", "data", "route_by_systems"]) @available_routes_by ["blueLoot", "redLoot", "thera", "turnur", "so_cleaning", "trade_hubs"] @@ -36,7 +58,7 @@ defmodule WandererApp.RouteBuilderClient do count: count || 1 } - case Req.post(url, Keyword.merge([json: payload], @timeout_opts)) do + case Req.post(url, Keyword.merge([json: payload], @timeout_opts ++ @connect_opts)) do {:ok, %{status: status, body: body}} when status in [200, 201] -> {:ok, body} diff --git a/lib/wanderer_app/structures.ex b/lib/wanderer_app/structures.ex index 7e1b32e5f..e3b641f18 100644 --- a/lib/wanderer_app/structures.ex +++ b/lib/wanderer_app/structures.ex @@ -5,7 +5,6 @@ defmodule WandererApp.Structure do require Logger alias WandererApp.Api.MapSystemStructure - alias WandererApp.Character def update_structures(system, added, updated, removed, main_character_eve_id, user_id \\ nil) do Logger.info("[Structure] update_structures called by user_id=#{inspect(user_id)}") @@ -24,14 +23,6 @@ defmodule WandererApp.Structure do :ok end - def search_corporation_names([], _search), do: {:ok, []} - - def search_corporation_names([first_char | _], search) when is_binary(search) do - Character.search(first_char.id, params: [search: search, categories: "corporation"]) - end - - def search_corporation_names(_user_chars, _search), do: {:ok, []} - defp parse_structures(list_of_maps, character_eve_id, system) do Logger.debug(fn -> "[Structure] parse_structures =>\n" <> inspect(list_of_maps, pretty: true) diff --git a/lib/wanderer_app/system_class.ex b/lib/wanderer_app/system_class.ex new file mode 100644 index 000000000..4a64b3101 --- /dev/null +++ b/lib/wanderer_app/system_class.ex @@ -0,0 +1,73 @@ +defmodule WandererApp.SystemClass do + @moduledoc """ + Canonical wormhole classification for EVE solar systems. + + Single source of truth for "is this class wormhole space", shared by the map + server's connection scoping and by server-side kill notifications. Mirrors + `assets/js/hooks/Mapper/components/map/helpers/isWormholeSpace.ts`. + """ + + require Logger + + @c1 1 + @c2 2 + @c3 3 + @c4 4 + @c5 5 + @c6 6 + @thera 12 + @c13 13 + @sentinel 14 + @barbican 15 + @vidette 16 + @conflux 17 + @redoubt 18 + + # c1-c6, Thera, c13 shattered frigate holes, and the five drifter systems. + @wormhole_classes [ + @c1, + @c2, + @c3, + @c4, + @c5, + @c6, + @c13, + @thera, + @sentinel, + @barbican, + @vidette, + @conflux, + @redoubt + ] + + @doc """ + The wormhole class ids. Exposed so callers needing a compile-time list (for + `in` checks in guards or hot paths) can derive theirs from this one rather + than restating it. + """ + @spec wormhole_classes() :: [pos_integer()] + def wormhole_classes, do: @wormhole_classes + + @spec wormhole?(integer() | nil) :: boolean() + def wormhole?(class) when class in @wormhole_classes, do: true + def wormhole?(_), do: false + + @doc """ + Resolves a solar system id to its class and reports whether it is wormhole + space. Returns false when static info cannot be resolved. + """ + @spec wormhole_system?(integer()) :: boolean() + def wormhole_system?(solar_system_id) do + case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do + {:ok, %{system_class: class}} -> + wormhole?(class) + + other -> + Logger.warning( + "[SystemClass] could not resolve static info for #{inspect(solar_system_id)}: #{inspect(other)}" + ) + + false + end + end +end diff --git a/lib/wanderer_app_web.ex b/lib/wanderer_app_web.ex index 858b4ff4a..042899efc 100644 --- a/lib/wanderer_app_web.ex +++ b/lib/wanderer_app_web.ex @@ -46,7 +46,7 @@ defmodule WandererAppWeb do import Phoenix.LiveView.Controller import Plug.Conn - import WandererAppWeb.Gettext + use Gettext, backend: WandererAppWeb.Gettext unquote(verified_routes()) end @@ -102,7 +102,7 @@ defmodule WandererAppWeb do import Phoenix.HTML # Core UI components and translation import WandererAppWeb.CoreComponents - import WandererAppWeb.Gettext + use Gettext, backend: WandererAppWeb.Gettext import WandererAppWeb.Helpers.CSP # Shortcut for generating JS commands diff --git a/lib/wanderer_app_web/api_router/routes.ex b/lib/wanderer_app_web/api_router/routes.ex index e90d0e6c9..43de42817 100644 --- a/lib/wanderer_app_web/api_router/routes.ex +++ b/lib/wanderer_app_web/api_router/routes.ex @@ -489,6 +489,20 @@ defmodule WandererAppWeb.ApiRoutes do }, # ACL Members API + %RouteSpec{ + verb: :get, + path: ~w(api v1 acls :acl_id members :member_id), + controller: WandererAppWeb.AccessListMemberAPIController, + action: :show_v1, + features: [], + metadata: %{ + auth_required: true, + rate_limit: :standard, + success_status: 200, + content_type: "application/vnd.api+json", + description: "Get a specific member from an access list" + } + }, %RouteSpec{ verb: :post, path: ~w(api v1 acls :acl_id members), diff --git a/lib/wanderer_app_web/components/core_components.ex b/lib/wanderer_app_web/components/core_components.ex index f8a53adba..c90e8d21f 100644 --- a/lib/wanderer_app_web/components/core_components.ex +++ b/lib/wanderer_app_web/components/core_components.ex @@ -17,7 +17,7 @@ defmodule WandererAppWeb.CoreComponents do use Phoenix.Component alias Phoenix.LiveView.JS - import WandererAppWeb.Gettext + use Gettext, backend: WandererAppWeb.Gettext @image_base_url "https://images.evetech.net" @@ -76,13 +76,24 @@ defmodule WandererAppWeb.CoreComponents do aria-modal="true" tabindex="0" > -
      + <%!-- The mask, not the dialog, is the scroll container. The dialog keeps + `overflow: visible` so absolutely-positioned children (LiveSelect + dropdowns, e.g. `maps_live.html.heex` add-map and every picker on + the Notifications tab) are never clipped โ€” the reason + `!overflow-visible` exists here at all. Scrolling out here reaches + a too-tall dialog without introducing a new clipping context. + + `m-auto` rather than `items-center`: a centred flex item taller + than a scrollable container overflows past its *start* edge, and + that region is unreachable by scrolling. Auto margins centre only + the slack, so a tall dialog pins to the top and stays reachable. --%> +
      <.focus_wrap id={"#{@id}-container"} phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")} phx-key="escape" class={[ - "relative hidden transition p-dialog p-component p-dialog-default p-ripple-disabled p-dialog-enter-done !overflow-visible max-w-full", + "relative hidden transition p-dialog p-component p-dialog-default p-ripple-disabled p-dialog-enter-done !overflow-visible max-w-full m-auto shrink-0", @class ]} > @@ -274,6 +285,28 @@ defmodule WandererAppWeb.CoreComponents do """ end + # Structural PrimeReact classes every button carries, kept separate from the + # variant modifier so the composed string for the default variant is + # byte-for-byte what this component rendered before variants existed. + @button_base_class "phx-submit-loading:opacity-75 p-button p-component" + @button_size_class "p-button-sm" + + # Variant -> PrimeReact severity modifier. The app loads the full PrimeReact + # theme (assets/css/app.css) on top of the local overrides in + # assets/js/hooks/Mapper/common-styles/prime-fixes/theme.scss, so these + # classes are already themed and stay in step with every React button on the + # map canvas. A parallel Tailwind palette here would drift from both. + # + # :primary carries no modifier on purpose โ€” bare `.p-button` *is* PrimeReact's + # filled primary style. (`p-button-primary`, passed ad hoc at a few call + # sites, is not defined by either stylesheet and styles nothing.) + @button_variant_classes %{ + primary: nil, + secondary: "p-button-outlined", + ghost: "p-button-text p-button-plain", + danger: "p-button-danger" + } + @doc """ Renders a button. @@ -281,30 +314,44 @@ defmodule WandererAppWeb.CoreComponents do <.button>Send! <.button phx-click="go" class="ml-2">Send! + <.button variant={:primary} type="submit">Save + <.button variant={:danger} phx-click="delete-everything">Remove all """ attr(:type, :string, default: nil) attr(:class, :string, default: nil) + + attr(:variant, :atom, + values: [:primary, :secondary, :ghost, :danger], + default: :secondary, + doc: """ + visual weight of the button: `:primary` is filled and belongs on a form's + single submit; `:secondary` is the default outlined style; `:ghost` is for + inline row actions such as Replace or removing a chip; `:danger` is + reserved for actions that destroy persisted state + """ + ) + attr(:data, :any, default: nil) attr(:rest, :global, include: ~w(disabled form name value)) slot(:inner_block, required: true) def button(assigns) do + assigns = assign(assigns, :variant_class, button_class(assigns.variant)) + ~H""" - """ end + defp button_class(variant) do + [@button_base_class, Map.fetch!(@button_variant_classes, variant), @button_size_class] + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + end + @doc """ Renders an input with label and error messages. @@ -566,7 +613,10 @@ defmodule WandererAppWeb.CoreComponents do def error(assigns) do ~H""" -

      + <%!-- rose-400, not rose-600: this app renders on a near-black panel, where + rose-600 measures about 3.8:1 and fails WCAG AA for body text. Error + text is the one place a contrast miss is least affordable. --%> +

      <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> {render_slot(@inner_block)}

      @@ -718,10 +768,46 @@ defmodule WandererAppWeb.CoreComponents do attr(:update_min_len, :integer, default: nil) attr(:available_option_class, :string, default: nil) attr(:value_mapper, :any, default: nil) + # Declared so callers inside a LiveComponent can scope the change/blur events to + # themselves. Both are forwarded only when set, so callers that omit them keep + # LiveSelect's own defaults (it derives an id from the field name). + attr(:id, :string, default: nil) + attr(:"phx-target", :any, default: nil) + # Drops the three pieces of chrome that make this wrapper taller than the + # input it contains, for callers that need the two to be the same height โ€” + # typically a two-column "field + Add button" row, where any cross-axis + # alignment otherwise lines the button up with the wrapper rather than with + # the field, leaving the button visibly high. + # + # 1. The `.label` row above the input. It renders a blank `label-text` + # regardless of `@label`, so it is pure spacer: it exists to line this + # field up with sibling fields that do carry a label. + # 2. LiveSelect's `tags_container`. Its template emits it unconditionally, + # including in `:single` mode where it can never hold a tag, and its + # default class carries `p-1` โ€” so an always-empty element contributes + # 8px directly above the input. Only suppressed in `:single` mode; + # in the tag modes that element holds the selection. + # 3. The error `.label` row below, unless there are errors to show. + # + # Defaults to false so every existing call site keeps its current spacing. + attr(:compact, :boolean, default: false) slot(:inner_block) slot(:option) def live_select(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + optional_opts = + Enum.reject( + [ + id: assigns[:id], + "phx-target": assigns[:"phx-target"], + # `_class` (override) rather than `_extra_class` (append): appending + # `hidden` to the default `flex` leaves the winner to Tailwind's + # stylesheet ordering, which is not something this should depend on. + tags_container_class: if(assigns[:compact] && assigns[:mode] == :single, do: "hidden") + ], + fn {_key, value} -> is_nil(value) end + ) + assigns = assigns |> assign(:errors, Enum.map(field.errors, &translate_error(&1))) @@ -734,8 +820,11 @@ defmodule WandererAppWeb.CoreComponents do :label_class, :input_class, :dropdown_extra_class, - :option_extra_class - ]) + :option_extra_class, + :compact, + :id, + :"phx-target" + ]) ++ optional_opts ) ~H""" @@ -746,7 +835,29 @@ defmodule WandererAppWeb.CoreComponents do @label_class ]} > -
      + <%!-- `:label` used to be accepted, stripped from the forwarded opts and + then never rendered, which left every combobox in the app with no + accessible name โ€” a screen reader announced five identical + "combobox, blank" controls on the Notifications tab alone. + + `for` targets LiveSelect's text input, whose id is derived from the + form and the `_text_input` name it registers internally, NOT + from the `id` we pass (that one lands on the LiveComponent wrapper). + Deriving it the same way LiveSelect does keeps the two in step. + + In `compact` mode the label is screen-reader-only. `compact` exists + to make this wrapper exactly as tall as its input so a neighbouring + button lines up with the field; a visible label row would undo that + for the "field + Add" grids that use it. Nothing loses a visible + label it had before โ€” this element did not render at all until now. --%> + +
      {render_slot(@inner_block)} -
      +
      <.error :for={msg <- @errors}>{msg}
      diff --git a/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex b/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex index 4f9bb67f9..55f96349e 100644 --- a/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex +++ b/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex @@ -11,6 +11,15 @@ defmodule WandererAppWeb.AccessListMemberAPIController do import Ash.Query require Logger + # ------------------------------------------------------------------------ + # V1 API Actions (for compatibility with versioned API router) + # ------------------------------------------------------------------------ + + def show_v1(conn, params), do: show(conn, params) + def create_v1(conn, params), do: create(conn, params) + def update_role_v1(conn, params), do: update_role(conn, params) + def delete_v1(conn, params), do: delete(conn, params) + # ------------------------------------------------------------------------ # Inline Schemas # ------------------------------------------------------------------------ @@ -95,10 +104,94 @@ defmodule WandererAppWeb.AccessListMemberAPIController do required: ["ok"] } + @acl_member_show_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string}, + eve_character_id: %OpenApiSpex.Schema{type: :string}, + eve_corporation_id: %OpenApiSpex.Schema{type: :string}, + eve_alliance_id: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "name", "role"] + } + }, + required: ["data"] + } + + # Every error body these three actions return has the same shape. Declared + # once so the `responses:` lists below stay readable โ€” they now have to + # enumerate four statuses each, all of which `with_membership/4` or the + # mutation branches can actually produce. + @error_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{error: %OpenApiSpex.Schema{type: :string}} + } + # ------------------------------------------------------------------------ # ENDPOINTS # ------------------------------------------------------------------------ + @doc """ + GET /api/acls/:acl_id/members/:member_id + + Retrieves a specific ACL member by ACL ID and member external ID (EVE character/corp/alliance ID). + """ + @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation(:show, + summary: "Get ACL Member", + description: + "Retrieves a specific ACL member identified by ACL ID and member external ID (EVE character, corporation, or alliance ID).", + parameters: [ + acl_id: [ + in: :path, + description: "Access List ID", + type: :string, + required: true + ], + member_id: [ + in: :path, + description: "Member external ID (EVE character, corporation, or alliance ID)", + type: :string, + required: true + ] + ], + responses: [ + ok: { + "ACL Member details", + "application/json", + @acl_member_show_response_schema + }, + not_found: { + "Member not found", + "application/json", + @error_response_schema + }, + conflict: { + "More than one membership matches the given ACL and external id", + "application/json", + @error_response_schema + }, + internal_server_error: { + "Membership lookup failed", + "application/json", + @error_response_schema + } + ] + ) + + def show(conn, %{"acl_id" => acl_id, "member_id" => external_id}) do + with_membership(conn, acl_id, external_id, fn membership -> + json(conn, %{data: member_to_json(membership)}) + end) + end + @doc """ POST /api/acls/:acl_id/members @@ -207,16 +300,25 @@ defmodule WandererAppWeb.AccessListMemberAPIController do json(conn, %{data: member_to_json(new_member)}) end - {:error, error} -> + {:error, %Ash.Error.Invalid{} = error} -> conn |> put_status(:bad_request) - |> json(%{error: "Creation failed: #{inspect(error)}"}) + |> json(%{error: "Validation failed", details: validation_messages(error)}) + + {:error, error} -> + Logger.error("[AccessListMemberAPI] member create failed: #{inspect(error)}") + + conn + |> put_status(:internal_server_error) + |> json(%{error: "Failed to create member"}) end else error -> + Logger.warning("[AccessListMemberAPI] #{type} lookup failed: #{inspect(error)}") + conn |> put_status(:bad_request) - |> json(%{error: "Entity lookup failed: #{inspect(error)}"}) + |> json(%{error: "Entity lookup failed"}) end end end @@ -255,6 +357,26 @@ defmodule WandererAppWeb.AccessListMemberAPIController do "Updated ACL Member", "application/json", @acl_member_update_response_schema + }, + bad_request: { + "Role rejected for this member type, or the update failed", + "application/json", + @error_response_schema + }, + not_found: { + "Member not found", + "application/json", + @error_response_schema + }, + conflict: { + "More than one membership matches the given ACL and external id", + "application/json", + @error_response_schema + }, + internal_server_error: { + "Membership lookup failed", + "application/json", + @error_response_schema } ] ) @@ -264,79 +386,60 @@ defmodule WandererAppWeb.AccessListMemberAPIController do "member_id" => external_id, "member" => member_params }) do - external_id_str = to_string(external_id) + with_membership(conn, acl_id, external_id, fn membership -> + new_role = Map.get(member_params, "role", membership.role) + + member_type = + cond do + membership.eve_corporation_id -> "corporation" + membership.eve_alliance_id -> "alliance" + membership.eve_character_id -> "character" + true -> "character" + end - membership_query = - AccessListMember - |> Ash.Query.new() - |> filter(access_list_id == ^acl_id) - |> filter( - eve_character_id == ^external_id_str or - eve_corporation_id == ^external_id_str or - eve_alliance_id == ^external_id_str - ) + if member_type in ["corporation", "alliance"] and new_role in ["admin", "manager"] do + conn + |> put_status(:bad_request) + |> json(%{ + error: "#{String.capitalize(member_type)} members cannot have an admin or manager role" + }) + else + case AccessListMember.update_role(membership, member_params) do + {:ok, updated_membership} -> + # Broadcast event to all maps using this ACL + case AclEventBroadcaster.broadcast_member_event( + acl_id, + updated_membership, + :acl_member_updated + ) do + :ok -> + broadcast_acl_updated(acl_id) - case Ash.read(membership_query) do - {:ok, [membership]} -> - new_role = Map.get(member_params, "role", membership.role) - - member_type = - cond do - membership.eve_corporation_id -> "corporation" - membership.eve_alliance_id -> "alliance" - membership.eve_character_id -> "character" - true -> "character" - end + json(conn, %{data: member_to_json(updated_membership)}) - if member_type in ["corporation", "alliance"] and new_role in ["admin", "manager"] do - conn - |> put_status(:bad_request) - |> json(%{ - error: - "#{String.capitalize(member_type)} members cannot have an admin or manager role" - }) - else - case AccessListMember.update_role(membership, member_params) do - {:ok, updated_membership} -> - # Broadcast event to all maps using this ACL - case AclEventBroadcaster.broadcast_member_event( - acl_id, - updated_membership, - :acl_member_updated - ) do - :ok -> - broadcast_acl_updated(acl_id) - - json(conn, %{data: member_to_json(updated_membership)}) + {:error, broadcast_error} -> + Logger.warning( + "Failed to broadcast ACL member updated event: #{inspect(broadcast_error)}" + ) - {:error, broadcast_error} -> - Logger.warning( - "Failed to broadcast ACL member updated event: #{inspect(broadcast_error)}" - ) + # Still broadcast internal message even if external broadcast fails + broadcast_acl_updated(acl_id) - # Still broadcast internal message even if external broadcast fails - broadcast_acl_updated(acl_id) + json(conn, %{data: member_to_json(updated_membership)}) + end - json(conn, %{data: member_to_json(updated_membership)}) - end + {:error, error} -> + # Same reason `with_membership/4` sanitizes its lookup errors: an + # Ash error serialized to the caller exposes resource and adapter + # internals. The detail belongs in the log, not the response. + Logger.warning("[AccessListMemberAPI] role update failed: #{inspect(error)}") - {:error, error} -> - conn - |> put_status(:bad_request) - |> json(%{error: inspect(error)}) - end + conn + |> put_status(:bad_request) + |> json(%{error: "Failed to update membership"}) end - - {:ok, []} -> - conn - |> put_status(:not_found) - |> json(%{error: "Membership not found for given ACL and external id"}) - - {:error, error} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: inspect(error)}) - end + end + end) end @doc """ @@ -367,11 +470,78 @@ defmodule WandererAppWeb.AccessListMemberAPIController do "ACL Member deletion confirmation", "application/json", @acl_member_delete_response_schema + }, + bad_request: { + "Deletion failed", + "application/json", + @error_response_schema + }, + not_found: { + "Member not found", + "application/json", + @error_response_schema + }, + conflict: { + "More than one membership matches the given ACL and external id", + "application/json", + @error_response_schema + }, + internal_server_error: { + "Membership lookup failed", + "application/json", + @error_response_schema } ] ) def delete(conn, %{"acl_id" => acl_id, "member_id" => external_id}) do + with_membership(conn, acl_id, external_id, fn membership -> + case AccessListMember.destroy(membership) do + :ok -> + # Broadcast event to all maps using this ACL + case AclEventBroadcaster.broadcast_member_event( + acl_id, + membership, + :acl_member_removed + ) do + :ok -> + broadcast_acl_updated(acl_id) + + json(conn, %{ok: true}) + + {:error, broadcast_error} -> + Logger.warning( + "Failed to broadcast ACL member removed event: #{inspect(broadcast_error)}" + ) + + # Still broadcast internal message even if external broadcast fails + broadcast_acl_updated(acl_id) + + json(conn, %{ok: true}) + end + + {:error, error} -> + Logger.warning("[AccessListMemberAPI] membership deletion failed: #{inspect(error)}") + + conn + |> put_status(:bad_request) + |> json(%{error: "Failed to delete membership"}) + end + end) + end + + # --------------------------------------------------------------------------- + # Private Helpers + # --------------------------------------------------------------------------- + + # `show/2`, `update_role/2` and `delete/2` all address a member by the ACL id + # plus one external EVE id, which may live in any of three columns. The lookup + # and every non-single-match outcome live here so the three actions cannot + # drift apart again โ€” they previously did, and two of them were missing the + # multi-match clause entirely (CaseClauseError) and leaked `inspect(error)`. + # + # `fun` runs only for exactly one match. + defp with_membership(conn, acl_id, external_id, fun) do external_id_str = to_string(external_id) membership_query = @@ -386,51 +556,63 @@ defmodule WandererAppWeb.AccessListMemberAPIController do case Ash.read(membership_query) do {:ok, [membership]} -> - case AccessListMember.destroy(membership) do - :ok -> - # Broadcast event to all maps using this ACL - case AclEventBroadcaster.broadcast_member_event( - acl_id, - membership, - :acl_member_removed - ) do - :ok -> - broadcast_acl_updated(acl_id) - - json(conn, %{ok: true}) - - {:error, broadcast_error} -> - Logger.warning( - "Failed to broadcast ACL member removed event: #{inspect(broadcast_error)}" - ) - - # Still broadcast internal message even if external broadcast fails - broadcast_acl_updated(acl_id) - - json(conn, %{ok: true}) - end - - {:error, error} -> - conn - |> put_status(:bad_request) - |> json(%{error: inspect(error)}) - end + fun.(membership) {:ok, []} -> conn |> put_status(:not_found) |> json(%{error: "Membership not found for given ACL and external id"}) + # More than one row matches when the same external id was recorded under + # two of the eve_character/corporation/alliance columns. Without this + # clause the `[membership]` match above raised CaseClauseError. + {:ok, [_ | _] = memberships} -> + Logger.warning( + "[AccessListMemberAPI] #{length(memberships)} memberships matched acl/external id" + ) + + conn + |> put_status(:conflict) + |> json(%{error: "Multiple memberships match the given ACL and external id"}) + {:error, error} -> + Logger.error("[AccessListMemberAPI] membership lookup failed: #{inspect(error)}") + conn |> put_status(:internal_server_error) - |> json(%{error: inspect(error)}) + |> json(%{error: "Failed to look up membership"}) end end - # --------------------------------------------------------------------------- - # Private Helpers - # --------------------------------------------------------------------------- + # Validation errors are the one class of Ash error a client legitimately needs + # back โ€” "role is invalid" is actionable, and swallowing it would make the + # endpoint unusable. Everything else is logged server-side and answered + # generically: `inspect(error)` on an arbitrary Ash error serializes the + # changeset, which carries internal field names, resource modules and the + # submitted attributes (CWE-209). + # + # There is deliberately no `inspect/1` fallback here. An error struct this + # does not recognise yields a fixed string rather than its contents. + # + # Public only so it can be unit tested: `WandererApp.Esi` hard-delegates to + # `Esi.ApiClient` (esi.ex:9), so the create path cannot be driven end-to-end + # without network access. + @doc false + def validation_messages(%Ash.Error.Invalid{errors: errors}) do + Enum.map(errors, fn + %{field: field, message: message} when not is_nil(field) and is_binary(message) -> + "#{field}: #{message}" + + %{message: message} when is_binary(message) -> + message + + %Ash.Error.Changes.NoSuchAttribute{attribute: attribute} -> + "Invalid attribute: #{attribute}" + + _ -> + "Invalid value" + end) + end defp broadcast_acl_updated(acl_id) do # Invalidate map_characters cache for all maps using this ACL diff --git a/lib/wanderer_app_web/controllers/health_controller.ex b/lib/wanderer_app_web/controllers/health_controller.ex new file mode 100644 index 000000000..b7bbdaa9f --- /dev/null +++ b/lib/wanderer_app_web/controllers/health_controller.ex @@ -0,0 +1,46 @@ +defmodule WandererAppWeb.HealthController do + @moduledoc """ + Machine liveness for Fly health checks. + + Answers one question: is this machine serving? It must never gain an + authentication, rate-limiting, or feature-flag plug โ€” see the `:health` + pipeline in the router. + + Database reachability is reported in the body but deliberately does not change + the status code. Fly kills a machine that fails its check, and under + `min_machines_running = 1` that is the only machine; a restart cannot repair an + external Postgres outage, so letting the database drive the status code would + turn a transient blip into a self-inflicted outage. + """ + use WandererAppWeb, :controller + + # Short on purpose. This endpoint is polled every few seconds; the Repo default + # of 15s would let a saturated database hold each request open long enough for + # polls to pile up on top of the problem. + @db_check_timeout_ms 2_000 + + def index(conn, _params) do + json(conn, %{ + status: "ok", + version: to_string(WandererApp.Env.vsn()), + database: if(database_reachable?(), do: "ok", else: "unreachable") + }) + end + + defp database_reachable? do + case Ecto.Adapters.SQL.query(WandererApp.Repo, "SELECT 1", [], timeout: @db_check_timeout_ms) do + {:ok, _} -> true + _ -> false + end + rescue + # Most query-level failures come back as an error tuple and are handled + # above; this catches the ones that raise instead. + _ -> false + catch + # `rescue` does not cover exits. If the connection pool is not alive, the + # GenServer.call inside DBConnection exits with :noproc or :timeout, which + # would otherwise crash the request into a 500 โ€” the exact status this + # endpoint exists to avoid returning for a database fault. + :exit, _ -> false + end +end diff --git a/lib/wanderer_app_web/controllers/map_audit_api_controller.ex b/lib/wanderer_app_web/controllers/map_audit_api_controller.ex index fb548bf32..468e7aeae 100644 --- a/lib/wanderer_app_web/controllers/map_audit_api_controller.ex +++ b/lib/wanderer_app_web/controllers/map_audit_api_controller.ex @@ -154,10 +154,10 @@ defmodule WandererAppWeb.MapAuditAPIController do result |> Map.put(:character, WandererAppWeb.MapEventHandler.map_ui_character_stat(character)) - |> Map.put(:event_name, WandererAppWeb.UserActivityItem.get_event_name(event_type)) + |> Map.put(:event_name, UserActivityItem.get_event_name(event_type)) |> Map.put( :event_data, - WandererAppWeb.UserActivityItem.get_event_data( + UserActivityItem.get_event_data( event_type, Jason.decode!(event_data) |> Map.drop(["character_id"]) ) diff --git a/lib/wanderer_app_web/gettext.ex b/lib/wanderer_app_web/gettext.ex index 737afb598..507443298 100644 --- a/lib/wanderer_app_web/gettext.ex +++ b/lib/wanderer_app_web/gettext.ex @@ -5,7 +5,7 @@ defmodule WandererAppWeb.Gettext do By using [Gettext](https://hexdocs.pm/gettext), your module gains a set of macros for translations, for example: - import WandererAppWeb.Gettext + use Gettext, backend: WandererAppWeb.Gettext # Simple translation gettext("Here is the string to translate") @@ -20,5 +20,5 @@ defmodule WandererAppWeb.Gettext do See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ - use Gettext, otp_app: :wanderer_app + use Gettext.Backend, otp_app: :wanderer_app end diff --git a/lib/wanderer_app_web/live/access_lists/access_lists_live.ex b/lib/wanderer_app_web/live/access_lists/access_lists_live.ex index 6df89603d..b7af9503b 100755 --- a/lib/wanderer_app_web/live/access_lists/access_lists_live.ex +++ b/lib/wanderer_app_web/live/access_lists/access_lists_live.ex @@ -378,9 +378,19 @@ defmodule WandererAppWeb.AccessListsLive do uniq_search_req_id = UUID.uuid4(:default) Task.async(fn -> - {:ok, options} = search(active_character_id, text) - - {:search_results, uniq_search_req_id, options} + # `search/2` reports an unusable ESI lookup as `{:error, reason}`. This task + # is linked to the LiveView, so hard-matching `{:ok, options}` here would + # turn a transient ESI failure into a crashed ACL session. Carry the failure + # back as data instead: the dropdown still empties, but the caller can tell + # "lookup failed" from "no such member" and say so. + case search(active_character_id, text) do + {:ok, options} -> + {:search_results, uniq_search_req_id, options, nil} + + {:error, reason} -> + Logger.warning("ACL member search failed: #{inspect(reason)}") + {:search_results, uniq_search_req_id, [], reason} + end end) {:noreply, socket |> assign(uniq_search_req_id: uniq_search_req_id)} @@ -395,10 +405,24 @@ defmodule WandererAppWeb.AccessListsLive do Process.demonitor(ref, [:flush]) case result do - {:search_results, ^uniq_search_req_id, options} -> + {:search_results, ^uniq_search_req_id, options, nil} -> send_update(LiveSelect.Component, options: options, id: member_search_id) {:noreply, socket |> assign(member_search_options: options)} + {:search_results, ^uniq_search_req_id, options, _reason} -> + # The dropdown is emptied either way, but without a flash an ESI outage + # is indistinguishable from "there is no such character or corporation", + # so the user retypes a name they know is correct. + send_update(LiveSelect.Component, options: options, id: member_search_id) + + {:noreply, + socket + |> assign(member_search_options: options) + |> put_flash( + :error, + "Member search is unavailable right now. This lookup runs as one of your characters โ€” if it keeps failing, re-authorise a character and try again." + )} + _ -> {:noreply, socket} end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex index 66a8a786e..e1d8e6c26 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_characters_event_handler.ex @@ -8,6 +8,10 @@ defmodule WandererAppWeb.MapCharactersEventHandler do alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler} + @refresh_delay 100 + # Rate limiting: 5 minutes in milliseconds + @clear_all_cooldown 5 * 60 * 1000 + def handle_server_event(%{event: :character_added, payload: character}, socket) do socket |> MapEventHandler.push_map_event( @@ -47,13 +51,13 @@ defmodule WandererAppWeb.MapCharactersEventHandler do # Uses the characters from the payload instead of fetching all from database def handle_server_event( %{event: :characters_updated, payload: %{characters: characters}}, - socket + %{assigns: %{map_id: map_id}} = socket ), do: socket |> MapEventHandler.push_map_event( "characters_updated", - characters |> Enum.map(&map_ui_character/1) + map_ui_characters_with_ready(characters, map_id) ) # Legacy handler for :characters_updated without payload (backwards compatibility) @@ -69,7 +73,7 @@ defmodule WandererAppWeb.MapCharactersEventHandler do characters = map_id |> WandererApp.Map.list_characters() - |> Enum.map(&map_ui_character/1) + |> map_ui_characters_with_ready(map_id) socket |> MapEventHandler.push_map_event( @@ -126,6 +130,22 @@ defmodule WandererAppWeb.MapCharactersEventHandler do ) end + def handle_server_event(%{event: :ready_characters_updated, payload: payload}, socket) do + socket + |> MapEventHandler.push_map_event( + "ready_characters_updated", + payload + ) + end + + def handle_server_event(%{event: :all_ready_characters_cleared, payload: payload}, socket) do + socket + |> MapEventHandler.push_map_event( + "all_ready_characters_cleared", + payload + ) + end + def handle_server_event(event, socket), do: MapCoreEventHandler.handle_server_event(event, socket) @@ -150,10 +170,45 @@ defmodule WandererAppWeb.MapCharactersEventHandler do } } = socket ) do - {:ok, tracking_data} = - WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) + case WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) do + {:ok, tracking_data} -> + {:reply, %{data: tracking_data}, socket} + + {:error, reason} -> + Logger.error("Failed to build tracking data: #{inspect(reason)}") + + {:reply, %{data: %{characters: [], main: nil, following: nil, ready_characters: []}}, + socket} + end + end - {:reply, %{data: tracking_data}, socket} + def handle_ui_event( + "getAllReadyCharacters", + _event, + %{ + assigns: %{ + map_id: map_id + } + } = socket + ) do + try do + case build_all_ready_characters_data(map_id) do + {:ok, ready_characters_data} -> + {:reply, %{data: ready_characters_data}, socket} + + {:error, reason} -> + Logger.error("Failed to build all ready characters data: #{inspect(reason)}") + {:reply, %{data: %{characters: []}}, socket} + end + rescue + error -> + Logger.error("Exception in getAllReadyCharacters: #{inspect(error)}") + {:reply, %{data: %{characters: []}}, socket} + catch + :exit, reason -> + Logger.error("Exit in getAllReadyCharacters: #{inspect(reason)}") + {:reply, %{data: %{characters: []}}, socket} + end end def handle_ui_event( @@ -306,6 +361,53 @@ defmodule WandererAppWeb.MapCharactersEventHandler do end end + def handle_ui_event( + "updateReadyCharacters", + %{"ready_character_eve_ids" => ready_character_eve_ids}, + %{assigns: %{map_id: map_id, current_user: %{id: current_user_id}}} = socket + ) + when is_list(ready_character_eve_ids) do + perform_update_ready_characters( + ready_character_eve_ids, + map_id, + current_user_id, + socket + ) + end + + # Anything that is not a list of ids โ€” a JSON object, a bare string โ€” is + # rejected here rather than reaching `Enum.filter/2` in + # `validate_ready_characters/3`, where a map would silently iterate as + # key/value tuples. + def handle_ui_event("updateReadyCharacters", _event, socket) do + {:reply, %{error: "Invalid ready_character_eve_ids"}, socket} + end + + def handle_ui_event( + "clearAllReadyCharacters", + _event, + %{assigns: %{map_id: map_id, current_user: %{id: current_user_id}}} = socket + ) do + # Check rate limiting for clear all operation + case claim_clear_all_slot(map_id) do + {:rate_limited, remaining_cooldown} -> + {:reply, + %{ + error: "rate_limited", + message: "Clear all function is on cooldown", + remaining_cooldown: remaining_cooldown + }, socket} + + :ok -> + # Slot claimed, continue with the operation + perform_clear_all_ready_characters(map_id, current_user_id, socket) + + {:error, reason} -> + Logger.error("Rate limit check failed: #{inspect(reason)}") + {:reply, %{error: "internal_error", message: "Failed to check rate limit"}, socket} + end + end + def handle_ui_event( "startTracking", %{"character_eve_id" => character_eve_id}, @@ -325,6 +427,223 @@ defmodule WandererAppWeb.MapCharactersEventHandler do def handle_ui_event(event, body, socket), do: MapCoreEventHandler.handle_ui_event(event, body, socket) + # Private functions + + defp perform_clear_all_ready_characters(map_id, current_user_id, socket) do + try do + # `%{map_id: ...}`, not a bare id: `read_by_map` declares `map_id` as an + # action argument and its `code_interface` define has no `args:`, so the + # positional form does not resolve. Every other call site in the app + # already passes the map form. + {:ok, map_user_settings} = + WandererApp.Api.MapUserSettings.read_by_map(%{map_id: map_id}) + + # Clear ready characters for all users + results = + Enum.map(map_user_settings, fn user_setting -> + case WandererApp.Api.MapUserSettings.update_ready_characters(user_setting, %{ + ready_characters: [] + }) do + {:ok, _updated_settings} -> + :ok + + {:error, reason} -> + Logger.error( + "Failed to clear ready characters for user #{user_setting.user_id}: #{inspect(reason)}" + ) + + {:error, reason} + end + end) + + # Check if all operations succeeded + failed_operations = Enum.filter(results, &(&1 != :ok)) + + if Enum.empty?(failed_operations) do + # Broadcast to all users that ready characters have been cleared. + # PubSub on the bare `map_id` topic, which is what `MapCoreEventHandler` + # subscribes to โ€” `Endpoint.broadcast!("map:#{map_id}", ...)` went to a + # topic with no subscribers (this app defines no Phoenix channels), so + # other users' clients never saw the clear. + Phoenix.PubSub.broadcast!(WandererApp.PubSub, map_id, %{ + event: :all_ready_characters_cleared, + payload: %{cleared_by_user_id: current_user_id} + }) + + # Build and return updated tracking data for current user + {:ok, tracking_data} = + WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) + + # Send characters_updated event to update all character data including ready status + Process.send_after(self(), %{event: :characters_updated}, @refresh_delay + 10) + + {:reply, %{data: tracking_data}, socket} + else + Logger.error("Some clear operations failed: #{inspect(failed_operations)}") + {:reply, %{error: "Failed to clear some ready characters"}, socket} + end + rescue + error -> + Logger.error("Exception in clear all ready characters: #{inspect(error)}") + {:reply, %{error: "Internal error while clearing ready characters"}, socket} + end + end + + defp perform_update_ready_characters( + ready_character_eve_ids, + map_id, + current_user_id, + socket + ) do + # Validate ready characters exist, are owned by user, and are tracked + with {:ok, valid_ready_characters} <- + validate_ready_characters(map_id, current_user_id, ready_character_eve_ids), + {:ok, map_user_settings} <- + WandererApp.MapUserSettingsRepo.get(map_id, current_user_id) do + do_update_ready_characters( + valid_ready_characters, + map_user_settings, + map_id, + current_user_id, + socket + ) + else + {:error, reason} -> + Logger.error("Failed to update ready characters: #{inspect(reason)}") + {:reply, %{error: "Failed to update ready characters"}, socket} + end + end + + defp do_update_ready_characters( + valid_ready_characters, + map_user_settings, + map_id, + current_user_id, + socket + ) do + result = + case map_user_settings do + nil -> + # Create new settings if none exist, then update with ready characters + case WandererApp.Api.MapUserSettings.create(%{ + map_id: map_id, + user_id: current_user_id, + settings: "{}" + }) do + {:ok, new_settings} -> + # Now update with ready characters + case WandererApp.Api.MapUserSettings.update_ready_characters(new_settings, %{ + ready_characters: valid_ready_characters + }) do + {:ok, _updated_settings} -> + :ok + + {:error, reason} -> + Logger.error( + "Failed to update ready characters on new settings: #{inspect(reason)}" + ) + + {:error, "Failed to save ready characters"} + end + + {:error, reason} -> + Logger.error("Failed to create user settings: #{inspect(reason)}") + {:error, "Failed to create user settings"} + end + + existing_settings -> + # Update existing settings + case WandererApp.Api.MapUserSettings.update_ready_characters(existing_settings, %{ + ready_characters: valid_ready_characters + }) do + {:ok, _updated_settings} -> + :ok + + {:error, reason} -> + Logger.error("Failed to update ready characters: #{inspect(reason)}") + {:error, "Failed to save ready characters"} + end + end + + case result do + :ok -> + # Broadcast ready status changes to other users in the map + broadcast_ready_status_change(map_id, current_user_id, valid_ready_characters) + + # Send characters_updated event to update all character data including ready status + Process.send_after(self(), %{event: :characters_updated}, @refresh_delay + 10) + + # Build and return updated tracking data immediately. Not strict-matched: + # the write already succeeded, so a failure here costs the caller its + # immediate refresh, not the update. + case WandererApp.Character.TrackingUtils.build_tracking_data(map_id, current_user_id) do + {:ok, tracking_data} -> + {:reply, %{data: tracking_data}, socket} + + {:error, reason} -> + Logger.error("Failed to build tracking data: #{inspect(reason)}") + {:reply, %{error: "Failed to load tracking data"}, socket} + end + + {:error, reason} -> + # The client pushed `updateReadyCharacters` and is awaiting a reply, so + # a bare `{:noreply, ...}` leaves that push permanently unsettled. Keep + # the flash and settle the push, as the sibling failure path in + # `perform_update_ready_characters/4` already does. + {:reply, %{error: reason}, socket |> put_flash(:error, reason)} + end + end + + # Both `characters_updated` paths must enrich with `:ready`. The payload path + # previously did not, so any broadcast carrying characters cleared the ready + # flag on the client until the next full refresh. + defp map_ui_characters_with_ready(characters, map_id) do + # MapSet, not a list: this is a membership test per character. + ready_eve_ids = + map_id |> WandererApp.MapUserSettingsRepo.ready_character_eve_ids() |> MapSet.new() + + Enum.map(characters, fn character -> + character + |> map_ui_character() + |> Map.put(:ready, MapSet.member?(ready_eve_ids, character.eve_id)) + end) + end + + # Claims the cooldown slot rather than reading it and writing later: a + # read-then-write pair let two concurrent clear-alls both observe "no + # previous run" and both proceed. `put_new/3` is a single atomic ETS + # operation, so exactly one caller wins. + # + # Nebulex's `:ttl` is in MILLISECONDS. Dividing the cooldown down to + # seconds made the entry expire after 360ms, so the rate limit never + # actually held and every request looked like the first one. + defp claim_clear_all_slot(map_id) do + cache_key = "map:#{map_id}:clear_all_ready_last_used" + current_time = System.system_time(:millisecond) + + if WandererApp.Cache.put_new(cache_key, current_time, ttl: @clear_all_cooldown) do + :ok + else + {:rate_limited, remaining_cooldown(cache_key, current_time)} + end + rescue + error -> + Logger.error("Error claiming clear all rate limit slot: #{inspect(error)}") + {:error, :cache_error} + end + + # The entry can expire between the failed claim and this read, in which case + # the caller simply sees a zero cooldown and can retry. + defp remaining_cooldown(cache_key, current_time) do + case WandererApp.Cache.get(cache_key) do + last_clear_time when is_integer(last_clear_time) -> + max(0, @clear_all_cooldown - (current_time - last_clear_time)) + + _ -> + 0 + end + end + def map_ui_character(character), do: character @@ -341,12 +660,13 @@ defmodule WandererAppWeb.MapCharactersEventHandler do |> Map.put(:alliance_ticker, Map.get(character, :alliance_ticker, "")) |> Map.put_new(:ship, WandererApp.Character.get_ship(character)) |> Map.put_new(:location, get_location(character)) + |> Map.put_new(:tracking_paused, character |> Map.get(:tracking_paused, false)) defp get_location(character), do: %{ - solar_system_id: character.solar_system_id, - structure_id: character.structure_id, - station_id: character.station_id + solar_system_id: Map.get(character, :solar_system_id), + structure_id: Map.get(character, :structure_id), + station_id: Map.get(character, :station_id) } def needs_tracking_setup?( @@ -417,4 +737,170 @@ defmodule WandererAppWeb.MapCharactersEventHandler do !is_tracked end) end + + # Validates that the provided character EVE IDs are valid. + # Returns {:ok, valid_character_eve_ids} or {:error, reason}. + defp validate_ready_characters(map_id, current_user_id, ready_character_eve_ids) do + with {:ok, user_characters_list} <- + WandererApp.Api.Character.active_by_user(%{user_id: current_user_id}), + user_character_ids = Enum.map(user_characters_list, & &1.id), + {:ok, character_settings} <- + WandererApp.MapCharacterSettingsRepo.get_by_map_filtered(map_id, user_character_ids) do + # Get valid user character EVE IDs + user_character_eve_ids = user_characters_list |> Enum.map(& &1.eve_id) |> MapSet.new() + + # Get tracked character IDs + tracked_character_ids = + character_settings + |> Enum.filter(& &1.tracked) + |> Enum.map(& &1.character_id) + |> MapSet.new() + + # Find tracked characters that match user characters + tracked_user_characters = + user_characters_list + |> Enum.filter(&MapSet.member?(tracked_character_ids, &1.id)) + |> Enum.map(& &1.eve_id) + |> MapSet.new() + + # Filter ready characters to only include owned, tracked characters + valid_ready_characters = + ready_character_eve_ids + |> Enum.filter(fn eve_id -> + MapSet.member?(user_character_eve_ids, eve_id) && + MapSet.member?(tracked_user_characters, eve_id) + end) + + {:ok, valid_ready_characters} + else + error -> + {:error, "Failed to validate characters: #{inspect(error)}"} + end + end + + # Broadcasts ready status changes to other users in the map. + defp broadcast_ready_status_change(map_id, current_user_id, _ready_character_eve_ids) do + # Not strict-matched: a failed user lookup must not take the LiveView down + # after the write has already committed. + case WandererApp.Api.User.by_id(current_user_id) do + {:ok, current_user} -> + # PubSub on the bare `map_id` topic (what `MapCoreEventHandler` + # subscribes to), not `Endpoint.broadcast!("map:#{map_id}", ...)` โ€” + # this app defines no Phoenix channels, so that topic had no + # subscribers and the event never reached anyone. + # + # The payload carries the map-wide ready set, not just this user's: + # the client applies `ready_character_eve_ids` to every character it + # holds, so sending one user's list cleared everybody else's flags. + Phoenix.PubSub.broadcast!(WandererApp.PubSub, map_id, %{ + event: :ready_characters_updated, + payload: %{ + user_id: current_user_id, + user_name: current_user.name, + ready_character_eve_ids: + WandererApp.MapUserSettingsRepo.ready_character_eve_ids(map_id) + } + }) + + {:error, reason} -> + Logger.warning( + "Skipping ready_characters_updated broadcast, user #{current_user_id} not found: #{inspect(reason)}" + ) + + :ok + end + end + + # Builds data for all ready characters from all users in the map. + defp build_all_ready_characters_data(map_id) do + with {:ok, ready_character_eve_ids} <- get_all_ready_character_eve_ids(map_id), + {:ok, tracked_characters} <- get_tracked_characters_with_settings(map_id), + {:ok, filtered_characters} <- + filter_ready_and_tracked_characters(tracked_characters, ready_character_eve_ids), + {:ok, enriched_characters} <- enrich_character_data(filtered_characters) do + {:ok, %{characters: enriched_characters}} + else + {:error, reason} -> + Logger.error("Failed to build ready characters data: #{inspect(reason)}") + {:ok, %{characters: []}} + end + end + + defp get_all_ready_character_eve_ids(map_id) do + case WandererApp.Api.MapUserSettings.read_by_map(%{map_id: map_id}) do + {:ok, map_user_settings} -> + ready_eve_ids = + map_user_settings + |> Enum.flat_map(fn settings -> + case settings.ready_characters do + nil -> [] + ready_chars when is_list(ready_chars) -> ready_chars + _ -> [] + end + end) + |> MapSet.new() + + {:ok, ready_eve_ids} + + {:error, reason} -> + {:error, reason} + end + end + + defp get_tracked_characters_with_settings(map_id) do + case WandererApp.Api.MapCharacterSettings.read_by_map(%{map_id: map_id}) do + {:ok, map_character_settings} -> + # Batch-loaded: `Enum.map(&Ash.load!(&1, :character))` issued one query + # per setting, and `Ash.load!` raises rather than returning the error + # tuple this function's contract promises. + case Ash.load(map_character_settings, :character) do + {:ok, settings_with_chars} -> {:ok, settings_with_chars} + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp filter_ready_and_tracked_characters(settings_with_chars, ready_eve_ids) do + filtered = + settings_with_chars + |> Enum.filter(fn setting -> + char = setting.character + # Character must exist, have a user_id, be tracked, and be in ready list + char != nil && + not is_nil(char.user_id) && + setting.tracked && + MapSet.member?(ready_eve_ids, char.eve_id) + end) + |> Enum.map(fn setting -> setting.character end) + + {:ok, filtered} + end + + defp enrich_character_data(characters) do + enriched = + Enum.map(characters, fn char -> + # Get actual online status + actual_online = + case WandererApp.Character.get_character_state(char.id, false) do + {:ok, %{is_online: is_online}} when not is_nil(is_online) -> is_online + _ -> Map.get(char, :online, false) + end + + character_data = + char + |> Map.put(:online, actual_online) + |> map_ui_character() + + %{ + character: character_data, + tracked: true, + ready: true + } + end) + + {:ok, enriched} + end end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_connections_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_connections_event_handler.ex index 0640cb302..b32700423 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_connections_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_connections_event_handler.ex @@ -145,8 +145,21 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do linked_sig_eve_id: nil }) - s - |> WandererApp.Api.MapSystemSignature.destroy!() + # Handle race conditions gracefully - signature may already be deleted + case Ash.destroy(s) do + :ok -> + :ok + + {:ok, _} -> + :ok + + {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Changes.StaleRecord{}]}} -> + # Already deleted by another process - this is fine + :ok + + {:error, error} -> + Logger.warning("Failed to delete signature #{s.eve_id}: #{inspect(error)}") + end end) # Audit log signatures deleted with connection @@ -314,9 +327,13 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do passages = passages |> Enum.map(fn p -> + # `%{p | character: p.character}` was a no-op that shipped the raw Ash + # struct to the client. Every other character-bearing payload goes + # through the stat serializer, which trims it to the UI fields and drops + # unloaded associations. %{ p - | character: p.character |> MapEventHandler.map_ui_character_stat() + | character: MapEventHandler.map_ui_character_stat(p.character) } |> Map.put_new( :ship, diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex index 60db6be6f..6feae07a4 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_core_event_handler.ex @@ -356,11 +356,193 @@ defmodule WandererAppWeb.MapCoreEventHandler do {:noreply, socket} end + def handle_ui_event( + "get_intel_source_maps", + _params, + %{ + assigns: %{ + current_user: current_user, + map_id: map_id, + user_permissions: %{manage_map: true} + } + } = socket + ) do + if WandererApp.Env.intel_sharing_enabled?() do + case WandererApp.Maps.get_available_maps(current_user) do + {:ok, maps} -> + character_ids = Enum.map(current_user.characters, & &1.id) + + candidates = + Enum.filter(maps, fn m -> m.id != map_id and not m.deleted end) + + loaded_candidates = + case Ash.load(candidates, :user_permissions, actor: current_user) do + {:ok, loaded} -> loaded + _ -> candidates + end + + eligible_maps = + loaded_candidates + |> Enum.filter(fn m -> + m.owner_id in character_ids or + has_manage_access?(m, character_ids) + end) + |> Enum.map(fn m -> %{id: m.id, name: m.name, slug: m.slug} end) + + {:reply, %{maps: eligible_maps}, socket} + + _error -> + {:reply, %{maps: []}, socket} + end + else + {:reply, %{maps: []}, socket} + end + end + + def handle_ui_event("get_intel_source_maps", _params, socket) do + {:reply, %{maps: []}, socket} + end + + def handle_ui_event( + "set_intel_source_map", + %{"intel_source_map_id" => source_map_id}, + %{ + assigns: %{ + map_id: map_id, + user_permissions: %{manage_map: true} + } + } = socket + ) do + if WandererApp.Env.intel_sharing_enabled?() do + source_map_id = if source_map_id in [nil, ""], do: nil, else: source_map_id + current_user = socket.assigns[:current_user] + + with {:ok, source_map} <- fetch_source_map(source_map_id), + :ok <- validate_no_circular_ref(map_id, source_map), + :ok <- validate_source_access(current_user, source_map), + {:ok, map} <- WandererApp.MapRepo.get(map_id), + {:ok, updated_map} <- + WandererApp.MapRepo.set_intel_source_map(map, source_map_id) do + # Update the map state cache so subsequent reads see the new value + WandererApp.Map.update_map_state(map_id, %{map: updated_map}) + + if source_map_id do + Task.Supervisor.start_child(WandererApp.TaskSupervisor, fn -> + WandererApp.Map.IntelSync.sync_all_visible_systems(map_id, source_map_id) + end) + end + + {:reply, + %{ + success: true, + intel_source_map_id: source_map_id + }, socket} + else + {:error, :circular_reference} -> + {:reply, %{success: false, error: "circular_reference"}, socket} + + {:error, :unauthorized_source} -> + {:reply, %{success: false, error: "unauthorized_source"}, socket} + + {:error, reason} -> + Logger.error("Failed to set intel source map: #{inspect(reason)}") + {:reply, %{success: false, error: "update_failed"}, socket} + end + else + {:reply, %{success: false, error: "feature_disabled"}, socket} + end + end + + # Without this clause a caller lacking `manage_map` fell through to the generic + # catch-all, which replies `{:noreply, ...}` โ€” the client's push awaited a reply + # that never came. Mirrors the `get_intel_source_maps` fallback above. + def handle_ui_event("set_intel_source_map", _params, socket) do + {:reply, %{success: false, error: "unauthorized"}, socket} + end + def handle_ui_event(event, body, socket) do Logger.debug(fn -> "unhandled map ui event: #{inspect(event)} #{inspect(body)}" end) {:noreply, socket} end + defp maybe_add_intel_source_info(options, map_id) do + if WandererApp.Env.intel_sharing_enabled?() do + case WandererApp.Map.get_map_state(map_id, false) do + {:ok, %{map: %{intel_source_map_id: source_id}}} -> + Map.put(options, "intel_source_map_id", source_id) + + _ -> + Map.put(options, "intel_source_map_id", nil) + end + else + options + end + end + + defp fetch_source_map(nil), do: {:ok, nil} + + defp fetch_source_map(source_map_id) do + case WandererApp.MapRepo.get(source_map_id) do + {:ok, source_map} -> {:ok, source_map} + _ -> {:error, :source_not_found} + end + end + + defp validate_no_circular_ref(_map_id, nil), do: :ok + + defp validate_no_circular_ref(map_id, %{id: source_id} = source_map) do + if source_id == map_id do + {:error, :circular_reference} + else + # Start walking from the source map's own intel_source_map_id + # (we already have the source map struct, no need to re-fetch it) + next_id = Map.get(source_map, :intel_source_map_id) + walk_intel_chain(next_id, MapSet.new([map_id, source_id])) + end + end + + defp walk_intel_chain(nil, _visited), do: :ok + + defp walk_intel_chain(current_id, visited) do + if MapSet.member?(visited, current_id) do + {:error, :circular_reference} + else + case WandererApp.MapRepo.get(current_id) do + {:ok, %{intel_source_map_id: next_id}} -> + walk_intel_chain(next_id, MapSet.put(visited, current_id)) + + _ -> + :ok + end + end + end + + defp validate_source_access(_current_user, nil), do: :ok + + defp validate_source_access(current_user, source_map) do + case WandererApp.Maps.get_user_role_for_map(source_map, current_user) do + role when role in [:admin, :manager] -> :ok + _ -> {:error, :unauthorized_source} + end + end + + defp has_manage_access?(map, character_ids) do + user_permissions = + case Map.get(map, :user_permissions) do + perms when is_list(perms) -> perms + _ -> [] + end + + permissions = + WandererApp.Permissions.get_map_permissions( + user_permissions, + map.owner_id, + character_ids + ) + + permissions.admin_map or permissions.manage_map + end + defp save_default_settings(map_id, settings, current_user) do # Find the character to use as actor actor = @@ -451,15 +633,17 @@ defmodule WandererAppWeb.MapCoreEventHandler do user_permissions, owner_id ) do - with user_permissions <- - WandererApp.Permissions.get_map_permissions( - user_permissions, - owner_id, - current_user_characters |> Enum.map(& &1.id) - ), - {:ok, map_user_settings} <- WandererApp.MapUserSettingsRepo.get(map_id, current_user_id), - {:ok, %{characters: available_map_characters}} = - WandererApp.Maps.load_characters(map, current_user_id) do + user_permissions = + WandererApp.Permissions.get_map_permissions( + user_permissions, + owner_id, + current_user_characters |> Enum.map(& &1.id) + ) + + with {:ok, map_user_settings} <- WandererApp.MapUserSettingsRepo.get(map_id, current_user_id) do + {:ok, %{characters: available_map_characters}} = + WandererApp.Maps.load_characters(map, current_user_id) + tracked_data = get_tracked_data( available_map_characters, @@ -694,6 +878,8 @@ defmodule WandererAppWeb.MapCoreEventHandler do map_id |> WandererApp.Map.get_options() + options = maybe_add_intel_source_info(options, map_id) + map_characters = map_id |> WandererApp.Map.list_characters() @@ -725,6 +911,7 @@ defmodule WandererAppWeb.MapCoreEventHandler do user_permissions: user_permissions, characters: map_characters, options: options, + client_env: WandererApp.Env.to_client_env(), classes: WandererApp.CachedInfo.get_wormhole_classes!(), wormholes: WandererApp.CachedInfo.get_wormhole_types!(), effects: WandererApp.CachedInfo.get_effects!(), diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_pings_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_pings_event_handler.ex index dd1c5ba59..9f8427ade 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_pings_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_pings_event_handler.ex @@ -239,15 +239,6 @@ defmodule WandererAppWeb.MapPingsEventHandler do {:noreply, socket} end - # Catch-all for cancel_ping to debug why it doesn't match - def handle_ui_event( - "cancel_ping", - event, - %{assigns: assigns} = socket - ) do - {:noreply, socket} - end - def handle_ui_event(event, body, socket), do: MapCoreEventHandler.handle_ui_event(event, body, socket) diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex index c95ee4e47..ce8738638 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_signatures_event_handler.ex @@ -189,6 +189,19 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do delete_timeout ) + # Get the system to clean up expired signatures + case WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{ + map_id: map_id, + solar_system_id: solar_system_id + }) do + {:ok, system} -> + # Clean up expired signatures before updating + WandererApp.Map.SignatureCleanup.cleanup_async(system.id) + + _ -> + :ok + end + map_id |> WandererApp.Map.Server.update_signatures(%{ solar_system_id: solar_system_id, @@ -222,6 +235,9 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do solar_system_id: get_integer(solar_system_id) }) do {:ok, system} -> + # Clean up expired signatures before returning them + WandererApp.Map.SignatureCleanup.cleanup_async(system.id) + removed_sig_eve_ids = Map.get(assigns, :removed_sig_eve_ids, []) system_signatures = diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex index 2baea2649..662a4207f 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_structures_event_handler.ex @@ -82,36 +82,6 @@ defmodule WandererAppWeb.MapStructuresEventHandler do end end - def handle_ui_event( - "get_corporation_names", - %{"search" => search}, - %{assigns: %{current_user: current_user}} = socket - ) do - user_chars = current_user.characters - - case Structure.search_corporation_names(user_chars, search) do - {:ok, results} -> - {:reply, %{results: results}, socket} - - {:error, reason} -> - Logger.warning("[MapStructuresEventHandler] corp search failed: #{inspect(reason)}") - {:reply, %{results: []}, socket} - - _ -> - {:reply, %{results: []}, socket} - end - end - - def handle_ui_event("get_corporation_ticker", %{"corp_id" => corp_id}, socket) do - case WandererApp.Esi.get_corporation_info(corp_id) do - {:ok, %{"ticker" => ticker}} -> - {:reply, %{ticker: ticker}, socket} - - _ -> - {:reply, %{ticker: nil}, socket} - end - end - defp get_map_system(map_id, solar_system_id) do case MapSystem.read_by_map_and_solar_system(%{ map_id: map_id, @@ -151,7 +121,8 @@ defmodule WandererAppWeb.MapStructuresEventHandler do :end_time, :inserted_at, :updated_at, - :structure_type + :structure_type, + :inherited_from_map_id ]) |> Map.update!(:inserted_at, &Calendar.strftime(&1, "%Y/%m/%d %H:%M:%S")) |> Map.update!(:updated_at, &Calendar.strftime(&1, "%Y/%m/%d %H:%M:%S")) diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_system_comments_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_system_comments_event_handler.ex index b26522ec1..add7f07b7 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_system_comments_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_system_comments_event_handler.ex @@ -166,14 +166,15 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do system: system, text: text, updated_at: updated_at - } = _comment + } = comment ) do %{ id: id, characterEveId: character.eve_id, solarSystemId: system.solar_system_id, text: text, - updated_at: updated_at + updated_at: updated_at, + inherited_from_map_id: comment.inherited_from_map_id } end @@ -185,7 +186,7 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do character: character, text: text, updated_at: updated_at - } = _comment, + } = comment, solar_system_id ) do %{ @@ -193,7 +194,8 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do characterEveId: character.eve_id, solarSystemId: solar_system_id, text: text, - updated_at: updated_at + updated_at: updated_at, + inherited_from_map_id: comment.inherited_from_map_id } end end diff --git a/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex b/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex index e6eef3b8f..f5b244413 100644 --- a/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex +++ b/lib/wanderer_app_web/live/map/event_handlers/map_systems_event_handler.ex @@ -4,6 +4,13 @@ defmodule WandererAppWeb.MapSystemsEventHandler do require Logger alias WandererAppWeb.{MapEventHandler, MapCoreEventHandler} + alias WandererApp.Map.Server.Impl + alias WandererApp.Character + + # Alliance hits are enriched with one ESI ticker lookup each, so this bounds + # network work, not just list length. Matches the cap + # `WandererApp.Esi.CorporationSearch` applies to the corporation dropdown. + @max_search_results 20 def handle_server_event(%{event: :add_system, payload: system}, socket) do # Schedule kill update for the new system after a short delay to allow subscription @@ -228,6 +235,101 @@ defmodule WandererAppWeb.MapSystemsEventHandler do {:noreply, socket} end + def handle_ui_event( + "update_system_owner", + %{"system_id" => sid} = params, + %{ + assigns: %{ + map_id: map_id, + map_loaded?: true, + current_user: current_user, + tracked_characters: tracked_characters, + user_permissions: user_permissions + } + } = socket + ) do + # Extract owner_id, owner_type, and owner_ticker from params using STRING keys + oid = Map.get(params, "owner_id") + otype = Map.get(params, "owner_type") + ticker = Map.get(params, "owner_ticker") + + # Clean up potential null/empty string values + oid = + case oid do + "null" -> nil + "" -> nil + val -> val + end + + otype = + case otype do + "null" -> nil + "" -> nil + val -> val + end + + ticker = + case ticker do + "null" -> nil + "" -> nil + val -> val + end + + if can_update_system?(:owner, user_permissions) do + system_id_int = + case sid do + id when is_integer(id) -> + id + + # `String.to_integer/1` raises on anything non-numeric, and `sid` + # comes straight off the wire โ€” a malformed value took the whole + # LiveView down instead of falling through to the nil guard below. + id when is_binary(id) -> + case Integer.parse(id) do + {int, ""} -> int + _ -> nil + end + + _ -> + nil + end + + if system_id_int do + WandererApp.Map.Server.update_system_owner( + map_id, + %{ + solar_system_id: system_id_int, + owner_id: oid, + owner_type: otype, + owner_ticker: ticker + } + ) + + main_character_id = + case tracked_characters do + [first | _] -> first.id + _ -> nil + end + + if main_character_id do + # Not strict-matched: activity tracking is best-effort telemetry, and + # a failure here must not roll back an owner update that already + # succeeded or crash the LiveView. + WandererApp.User.ActivityTracker.track_map_event(:system_updated, %{ + character_id: main_character_id, + user_id: current_user.id, + map_id: map_id, + solar_system_id: system_id_int, + key: :owner, + value: %{owner_id: oid, owner_type: otype, ticker: ticker} + }) + end + end + end + + {:reply, %{}, socket} + end + def handle_ui_event( "update_system_" <> param, %{"system_id" => solar_system_id, "value" => value} = _event, @@ -251,6 +353,7 @@ defmodule WandererAppWeb.MapSystemsEventHandler do "locked" -> :update_system_locked "tag" -> :update_system_tag "temporary_name" -> :update_system_temporary_name + "custom_flags" -> :update_system_custom_flags "status" -> :update_system_status _ -> nil end @@ -263,6 +366,7 @@ defmodule WandererAppWeb.MapSystemsEventHandler do "locked" -> :locked "tag" -> :tag "temporary_name" -> :temporary_name + "custom_flags" -> :custom_flags "status" -> :status _ -> :none end @@ -286,7 +390,7 @@ defmodule WandererAppWeb.MapSystemsEventHandler do }) end - {:noreply, socket} + {:reply, %{}, socket} end def handle_ui_event( @@ -346,8 +450,173 @@ defmodule WandererAppWeb.MapSystemsEventHandler do {:noreply, socket} end - def handle_ui_event(event, body, socket), - do: MapCoreEventHandler.handle_ui_event(event, body, socket) + def handle_ui_event( + "sync_intel", + %{"solar_system_id" => solar_system_id}, + %{assigns: %{map_id: map_id, map_loaded?: true, user_permissions: %{update_system: true}}} = + socket + ) do + if WandererApp.Env.intel_sharing_enabled?() do + case WandererAppWeb.Helpers.APIUtils.parse_int(solar_system_id) do + {:error, _} -> + {:reply, %{success: false, error: "invalid_system_id"}, socket} + + {:ok, solar_system_id_int} -> + case WandererApp.MapRepo.get(map_id) do + {:ok, %{intel_source_map_id: source_map_id}} when not is_nil(source_map_id) -> + case WandererApp.Map.IntelSync.sync_system( + map_id, + source_map_id, + solar_system_id_int + ) do + {:ok, updated_system} when is_map(updated_system) -> + intel_fields = WandererApp.Map.IntelSync.intel_fields() + + update = + Map.take(updated_system, [:solar_system_id | intel_fields]) + + WandererApp.Map.update_system_by_solar_system_id(map_id, update) + Impl.broadcast!(map_id, :update_system, updated_system) + + {:reply, %{success: true}, socket} + + _ -> + {:reply, %{success: false, error: "no_source_data"}, socket} + end + + _ -> + {:reply, %{success: false, error: "no_intel_source"}, socket} + end + end + else + {:reply, %{success: false, error: "feature_disabled"}, socket} + end + end + + def handle_ui_event("sync_intel", _params, socket) do + {:reply, %{success: false, error: "forbidden"}, socket} + end + + # Handle UI events for getting corporation names + def handle_ui_event("get_corporation_names", %{"search" => search}, socket) do + user_chars = socket.assigns.current_user.characters + + response = + case WandererApp.Esi.CorporationSearch.search(user_chars, search) do + {:ok, results} -> + %{results: results} + + other -> + # The reply shape stays `%{results: []}` because the JS consumers only + # read `.results`, but a failed ESI lookup must not be indistinguishable + # from "no such corporation" in the logs as well as in the dropdown. + Logger.warning("get_corporation_names failed: #{inspect(other)}") + %{results: []} + end + + {:reply, response, socket} + end + + # Handle UI events for getting alliance names + def handle_ui_event("get_alliance_names", %{"search" => search}, socket) do + user_chars = socket.assigns.current_user.characters + + response = + case search_alliance_names(user_chars, search) do + {:ok, results} -> + %{results: results} + + other -> + Logger.warning("get_alliance_names failed: #{inspect(other)}") + %{results: []} + end + + {:reply, response, socket} + end + + # Handle UI events for getting corporation ticker + def handle_ui_event("get_corporation_ticker", %{"corp_id" => corp_id}, socket) do + case WandererApp.Esi.get_corporation_info(corp_id) do + {:ok, %{"ticker" => ticker}} -> + {:reply, %{ticker: ticker}, socket} + + _error -> + {:reply, %{ticker: nil}, socket} + end + end + + # Handle UI events for getting alliance ticker + def handle_ui_event("get_alliance_ticker", %{"alliance_id" => alliance_id}, socket) do + case WandererApp.Esi.get_alliance_info(alliance_id) do + {:ok, %{"ticker" => ticker}} -> + {:reply, %{ticker: ticker}, socket} + + _error -> + {:reply, %{ticker: nil}, socket} + end + end + + # Fallback for update_system_owner when map isn't fully loaded + def handle_ui_event( + "update_system_owner", + _params, + %{assigns: %{map_loaded?: false}} = socket + ) do + Logger.debug("[MapSystemsEventHandler] Ignoring update_system_owner - map not loaded yet") + {:reply, %{}, socket} + end + + def handle_ui_event( + "update_system_owner", + _params, + %{assigns: assigns} = socket + ) + when not is_map_key(assigns, :map_id) or not is_map_key(assigns, :tracked_characters) do + Logger.debug( + "[MapSystemsEventHandler] Ignoring update_system_owner - missing required assigns" + ) + + {:reply, %{}, socket} + end + + # Fallback for update_system_custom_flags when map isn't fully loaded + def handle_ui_event( + "update_system_custom_flags", + _params, + %{assigns: %{map_loaded?: false}} = socket + ) do + Logger.debug( + "[MapSystemsEventHandler] Ignoring update_system_custom_flags - map not loaded yet" + ) + + {:reply, %{}, socket} + end + + def handle_ui_event( + "update_system_custom_flags", + _params, + %{assigns: assigns} = socket + ) + when not is_map_key(assigns, :map_id) or not is_map_key(assigns, :main_character_id) do + Logger.debug( + "[MapSystemsEventHandler] Ignoring update_system_custom_flags - missing required assigns" + ) + + {:reply, %{}, socket} + end + + # Catch-all for UI events NOT handled by specific clauses above + def handle_ui_event(event, params, socket) do + # Deliberately does NOT log `socket.assigns`: it carries `current_user` + # (with its characters and tokens) and the whole map state, which is both + # a credential-leak risk and megabytes of noise in the log. + Logger.warning("[MapSystemsEventHandler - UNMATCHED] Received event: #{inspect(event)}") + + # Forward to the core event handler as a fallback + MapCoreEventHandler.handle_ui_event(event, params, socket) + end + + # --- Private helpers --- def map_system( %{ @@ -394,4 +663,67 @@ defmodule WandererAppWeb.MapSystemsEventHandler do }) defp update_system_position(_map_id, _position), do: :ok + + defp search_alliance_names([], _search), do: {:ok, []} + + defp search_alliance_names([first_char | _], search) when is_binary(search) do + if String.length(search) < 3 do + {:ok, []} + else + result = + Character.search(first_char.id, params: [search: search, categories: "alliance"]) + + case result do + {:ok, results} -> + # ESI ticker lookups run concurrently and bounded: sequentially, a + # cold cache meant one blocking round-trip per search hit inside the + # LiveView process, so a broad prefix stalled the whole session. + # + # `max_concurrency` bounds how many run at once, not how many run. + # Truncate first โ€” otherwise a broad prefix still costs one ESI call + # per match and blocks this process until the whole stream drains. + # Same cap the system search applies above. + formatted_results = + results + |> Enum.take(@max_search_results) + |> Task.async_stream(&format_alliance_result/1, + max_concurrency: 10, + timeout: :timer.seconds(10), + on_timeout: :kill_task + ) + |> Enum.flat_map(fn + {:ok, item} -> [item] + {:exit, _reason} -> [] + end) + + {:ok, formatted_results} + + other -> + other + end + end + end + + defp search_alliance_names(_user_chars, _search), do: {:ok, []} + + defp format_alliance_result(item) do + name = Map.get(item, :label, "") + alliance_id = Map.get(item, :value, "") + + ticker = + case WandererApp.Esi.get_alliance_info(alliance_id) do + {:ok, %{"ticker" => ticker}} -> ticker + _ -> "" + end + + formatted_label = if ticker && ticker != "", do: "[#{ticker}] #{name}", else: name + + Map.merge(item, %{ + formatted: formatted_label, + name: name, + ticker: ticker, + id: item.value, + type: "alliance" + }) + end end diff --git a/lib/wanderer_app_web/live/map/map_characters_live.ex b/lib/wanderer_app_web/live/map/map_characters_live.ex index f09783967..d7e955359 100755 --- a/lib/wanderer_app_web/live/map/map_characters_live.ex +++ b/lib/wanderer_app_web/live/map/map_characters_live.ex @@ -84,7 +84,38 @@ defmodule WandererAppWeb.MapCharactersLive do character_setting -> case character_setting.tracked do true -> - WandererApp.Map.Server.untrack_characters(map_id, [character_setting.character_id]) + # The DB write has to happen here as well as the tracker call: this + # handler used to call untrack_characters/2 alone, which left the + # settings row saying tracked: true. Every other reader โ€” this + # page's own button included โ€” then reported the character as + # tracked, and the operator's action showed no effect anywhere it + # could be seen. + # + # This does not make the untrack permanent. track_character/2 + # (map_server_characters_impl.ex:1077) deliberately re-tracks a + # character whose settings say tracked: false when they next enter + # presence with valid tokens and permissions, so an untracked + # character returns on their next map entry by design. + # The tracker call runs whether or not the settings write succeeds: + # a failed write leaves the row stale, which is the state the + # previous code always produced, but it must not also skip the + # untrack. + case WandererApp.MapCharacterSettingsRepo.untrack(character_setting) do + {:ok, _updated} -> + :ok + + error -> + Logger.error( + "[MapCharacters] Failed to persist untrack for character " <> + "#{character_setting.character_id} on map #{map_id}: #{inspect(error)}" + ) + end + + WandererApp.Map.Server.untrack_characters( + map_id, + [character_setting.character_id], + :manual_untrack + ) socket |> put_flash(:info, "Character untracked!") |> load_characters() @@ -133,10 +164,32 @@ defmodule WandererAppWeb.MapCharactersLive do end defp load_characters(%{assigns: %{map_id: map_id}} = socket) do + {:ok, character_settings} = + case WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id) do + {:ok, settings} -> {:ok, settings} + _ -> {:ok, []} + end + + # `tracked` is rendered from the settings row rather than from the presence + # cache the character arrived in. The untrack this page performs writes the + # settings row and cannot write another user's presence entry, so reading + # presence here showed the Untrack button still lit after a successful + # untrack. Derive the displayed state from the state the action writes. + tracked_by_character_id = + character_settings + |> Map.new(fn setting -> {setting.character_id, setting.tracked} end) + map_characters = map_id |> get_all_characters() |> Enum.map(fn character -> map_ui_character(map_id, character) end) + |> Enum.map(fn character -> + Map.put( + character, + :tracked, + Map.get(tracked_by_character_id, character.id, Map.get(character, :tracked, false)) + ) + end) groups = map_characters @@ -145,12 +198,6 @@ defmodule WandererAppWeb.MapCharactersLive do acc ++ [%{id: user_id, characters: values}] end) - {:ok, character_settings} = - case WandererApp.MapCharacterSettingsRepo.get_all_by_map(map_id) do - {:ok, settings} -> {:ok, settings} - _ -> {:ok, []} - end - socket |> assign(:character_settings, character_settings) |> assign(:characters_count, map_characters |> length()) diff --git a/lib/wanderer_app_web/live/map/map_event_handler.ex b/lib/wanderer_app_web/live/map/map_event_handler.ex index 247caea12..9862653ea 100644 --- a/lib/wanderer_app_web/live/map/map_event_handler.ex +++ b/lib/wanderer_app_web/live/map/map_event_handler.ex @@ -25,15 +25,20 @@ defmodule WandererAppWeb.MapEventHandler do :present_characters_updated, :refresh_user_characters, :show_tracking, - :untrack_character + :untrack_character, + :ready_characters_updated, + :all_ready_characters_cleared ] @map_characters_ui_events [ "getCharacterInfo", "getCharactersTrackingInfo", + "getAllReadyCharacters", + "clearAllReadyCharacters", "updateCharacterTracking", "updateFollowingCharacter", "updateMainCharacter", + "updateReadyCharacters", "startTracking" ] @@ -58,7 +63,17 @@ defmodule WandererAppWeb.MapEventHandler do "update_system_tag", "update_system_temporary_name", "update_system_status", - "manual_paste_systems_and_connections" + "get_user_hubs", + "add_user_hub", + "delete_user_hub", + "update_system_owner", + "get_corporation_names", + "get_corporation_ticker", + "get_alliance_names", + "get_alliance_ticker", + "update_system_custom_flags", + "manual_paste_systems_and_connections", + "sync_intel" ] @map_system_comments_events [ @@ -139,9 +154,7 @@ defmodule WandererAppWeb.MapEventHandler do @map_structures_ui_events [ "update_structures", - "get_structures", - "get_corporation_names", - "get_corporation_ticker" + "get_structures" ] @map_kills_events [ @@ -322,8 +335,9 @@ defmodule WandererAppWeb.MapEventHandler do def map_ui_character_stat(nil), do: nil - def map_ui_character_stat(character), - do: + def map_ui_character_stat(character) do + # Take only the basic fields first + base_character = character |> Map.take([ :eve_id, @@ -331,9 +345,32 @@ defmodule WandererAppWeb.MapEventHandler do :corporation_id, :corporation_ticker, :alliance_id, - :alliance_ticker + :alliance_ticker, + :ship_name, + :online ]) + # Add optional fields only if they're loaded (not Ash.NotLoaded) + base_character = + base_character + |> maybe_add_field(character, :solar_system_id) + |> maybe_add_field(character, :structure_id) + |> maybe_add_field(character, :station_id) + |> maybe_add_field(character, :ship) + + # Add ship type information + ship_info = WandererApp.Character.get_ship(character) + base_character |> Map.put(:ship_info, ship_info) + end + + defp maybe_add_field(map, source, field) do + case Map.get(source, field) do + %Ash.NotLoaded{} -> map + nil -> map + value -> Map.put(map, field, value) + end + end + def map_ui_connection( %{ solar_system_source: solar_system_source, @@ -371,9 +408,18 @@ defmodule WandererAppWeb.MapEventHandler do temporary_name: temporary_name, status: status, visible: visible - } = _system, - include_static_data? \\ true + } = system, + _include_static_data? \\ true ) do + system_static_info = get_system_static_info(solar_system_id) + + system_signatures = + system_id + |> WandererAppWeb.MapSignaturesEventHandler.get_system_signatures() + |> Enum.filter(fn signature -> + is_nil(signature.linked_system) && signature.group == "Wormhole" + end) + comments_count = system_id |> WandererApp.Maps.get_system_comments_activity() @@ -385,30 +431,36 @@ defmodule WandererAppWeb.MapEventHandler do 0 end - system_info = - %{ - id: "#{solar_system_id}", - position: %{x: position_x, y: position_y}, - description: description, - name: name, - labels: labels, - locked: locked, - linked_sig_eve_id: linked_sig_eve_id, - status: status, - tag: tag, - temporary_name: temporary_name, - comments_count: comments_count, - visible: visible - } + result = %{ + id: "#{solar_system_id}", + position: %{x: position_x, y: position_y}, + description: description, + name: name, + system_static_info: system_static_info, + system_signatures: system_signatures, + labels: labels, + locked: locked, + linked_sig_eve_id: linked_sig_eve_id, + status: status, + tag: tag, + temporary_name: temporary_name, + comments_count: comments_count, + visible: visible + } + + Map.merge(result, zoo_system_fields(system)) + end - system_info = - if include_static_data? do - system_info |> Map.merge(%{system_static_info: get_system_static_info(solar_system_id)}) - else - system_info - end + @zoo_system_keys [:owner_type, :owner_id, :owner_ticker, :custom_flags] - system_info + defp zoo_system_fields(system) do + system + |> Map.take(@zoo_system_keys) + |> Enum.reduce(%{}, fn + {_key, nil}, acc -> acc + {_key, ""}, acc -> acc + {key, value}, acc -> Map.put(acc, key, value) + end) end def map_ui_system_static_info(nil), do: %{} diff --git a/lib/wanderer_app_web/live/maps/components/map_notifications_component.ex b/lib/wanderer_app_web/live/maps/components/map_notifications_component.ex new file mode 100644 index 000000000..3c58e4c18 --- /dev/null +++ b/lib/wanderer_app_web/live/maps/components/map_notifications_component.ex @@ -0,0 +1,2490 @@ +defmodule WandererAppWeb.MapNotificationsComponent do + @moduledoc """ + Settings tab for per-map Discord kill notifications. + + A map has one notification record and up to three destinations: a `:system` + webhook (kills in systems on the map), an optional `:character` webhook + (kills involving characters tracked on the map) and an optional `:route` + webhook (highsec-route-to-Jita alerts). Each destination has its own + URL, enable flag, delivery status and test button, because an owner needs to + be able to diagnose one channel without touching the other. + + Webhook URLs are credentials: stored encrypted, only ever rendered as a masked + hint with a replace flow, like a password field. + + The tab is organised around TWO features rather than three peer destinations, + because that is what the schema actually models: kill notifications (kill + channel, optional character-kill-channel split, filters) and route alerts (a + separate feature borrowing the same webhook plumbing โ€” its own toggle, + channel, home system, max jumps and mentions). + + Neither feature gates the other. There is no "no record yet" screen asking + for a kill webhook first: the policy row is created lazily by whichever + control the operator touches first (`upsert/2`, `ensure_notification/1`), and + `MapDiscordNotification`'s `create` takes an optional `webhook_url` for + exactly that reason. An operator who wants route alerts and nothing else + configures route alerts and nothing else. + + The organising rule is *show what is true now; everything that changes it + goes behind an edit*. At rest the tab is a short list of statements โ€” which + destinations exist, where they post, whether they are delivering โ€” and the + controls that would change any of that are one click away. This is a hard + constraint, not a preference: the settings dialog has no height of its own, + so an expanded-by-default tab is a tab whose lower half cannot be reached on + a laptop. + + Layout: + + Kill notifications โ€” the two map-level switches (which apply on change), + then the kill channel and the character kill channel + as truth lines with an Edit/Add affordance, then a + "Kill filters" disclosure. The switches sit above both + rows because they govern both. + Route alerts โ€” the toggle, home system and max jumps as one form + (they are validated against each other, so they commit + together) with the one Save that commits them, then + the route channel, then a "Mentions" disclosure. + Message region โ€” the foot of the tab. Every control reports here. + + Every control acts on the thing it sits next to; the tab has no global + buttons. The route trio is the only group with a Save at all, and it has one + because those three fields cannot commit on change: `route_max_jumps` is + typed (a per-keystroke save submits "" mid-edit against an `allow_nil? false` + attribute) and the toggle is validated against the home system, so committing + it alone reports a missing field the operator has not reached yet. There is + deliberately no wholesale "remove everything": each destination has its own + Remove, and a policy row with no destinations delivers nothing. + + Warnings and results never render inside a disclosure: something inside a + collapsed body has not been shown to anyone. + + Disclosure state (`@open_sections`) and per-row edit state + (`@replacing_url?`) both live on the server. Client-only `JS.toggle_class` + was tried first and is wrong here: this component re-renders on saves, + PubSub ticks and background channel-identity refreshes, each of which + re-sends the literal collapsed markup and slams an open section shut. + """ + + use WandererAppWeb, :live_component + + require Logger + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.Api.MapDiscordWebhook + alias WandererApp.Api.MapSolarSystem + alias WandererApp.Esi.CorporationSearch + alias WandererApp.ExternalEvents.Discord.ChannelInfo + alias WandererApp.ExternalEvents.Discord.Guild + alias WandererApp.ExternalEvents.Discord.Mentions + + @excluded_select_id "excluded_system_live_select_component" + @focus_corp_select_id "focus_corp_live_select_component" + @home_system_select_id "home_system_live_select_component" + @mention_user_select_id "mention_user_live_select_component" + @mention_role_select_id "mention_role_live_select_component" + @min_search_length 2 + @max_search_results 20 + + @roles [:system, :character, :route] + + # Mirrors the resource's own default (map_discord_notification.ex:166), so a + # blank/non-numeric max-jumps input falls back to the same number a brand new + # record would get rather than an unrelated magic value. + @default_route_max_jumps 5 + # Mirrors the resource's `constraints min: 1, max: 20` (map_discord_notification.ex:173). + @min_route_max_jumps 1 + @max_route_max_jumps 20 + + # Shown under the corporation box when the lookup itself failed, as opposed to + # succeeding with no matches. + @corp_search_error "Corporation search is unavailable right now. This lookup runs as one of your characters โ€” if it keeps failing, re-authorise a character and try again." + + # The failure reason is rendered alongside the message rather than only logged. + # This surface is map-admin-only and the reasons are ESI status atoms + # (`:forbidden`, `:not_found`, `:timeout`, `:error_limited`), not secrets. + # Without it the banner is identical for "this character needs re-authorising" + # and "ESI is rate-limiting us", which is the difference between a user-fixable + # problem and one they should wait out โ€” and it makes the failure diagnosable + # from a screenshot instead of requiring server log access. + # + # The character is named for the same reason. `CorporationSearch.search/3` runs + # as the FIRST of the user's characters and only that one, so on a + # multi-character account a single stale token breaks the feature while every + # other character is fine. Telling the user to "re-authorise a character" is + # then actively misleading: re-authorising any of the others changes nothing. + # Naming it also distinguishes the two failures that look identical from the + # outside โ€” a character-specific token problem versus ESI being down for + # everything. + defp corp_search_error(reason, characters) do + case CorporationSearch.search_character(characters) do + {:ok, %{name: name}} when is_binary(name) and name != "" -> + "Corporation search is unavailable right now. It runs as #{name}, so re-authorise" <> + " that character specifically โ€” re-authorising a different one will not help." <> + " (#{inspect(reason)})" + + _ -> + "#{@corp_search_error} (#{inspect(reason)})" + end + end + + # Shown under the excluded-systems box when the lookup itself failed. Unlike the + # corporation search this one never leaves the app, so a failure here means the + # database or the Ash read is unhealthy rather than anything the user can fix. + @system_search_error "System search is unavailable right now. Try again in a moment." + + @impl true + def update(%{channel_info_refreshed: true}, socket) do + # A background channel-identity refresh landed and the cache is now warm. + # Only the hints are recomputed: the refresh wrote cached identity and + # nothing else, so reloading the record here would throw away whatever the + # operator has typed into the forms since the tab was opened. + {:ok, + socket + |> assign_channel_hints(socket.assigns.webhooks) + |> assign_mention_guild(socket.assigns.webhooks[:route]) + |> request_guild_roles()} + end + + # The guild's role list landed. Only accepted for the guild currently on + # screen: an operator who repointed the route destination while the request + # was in flight must not get the previous guild's roles offered as if they + # were valid here โ€” they would save cleanly and ping nobody. + def update(%{guild_roles: {guild_id, result}}, socket) do + if guild_id == socket.assigns[:mention_guild_id] do + {:ok, + socket + |> assign(:guild_roles, result) + |> learn_role_labels(result)} + else + {:ok, socket} + end + end + + def update(%{map_id: map_id} = assigns, socket) do + notification = + case MapDiscordNotification.by_map(map_id) do + {:ok, rec} -> rec + _ -> nil + end + + {:ok, + socket + |> assign(assigns) + |> assign(:excluded_select_id, @excluded_select_id) + |> assign(:focus_corp_select_id, @focus_corp_select_id) + |> assign(:home_system_select_id, @home_system_select_id) + |> assign(:mention_user_select_id, @mention_user_select_id) + |> assign(:mention_role_select_id, @mention_role_select_id) + |> assign(:min_search_length, @min_search_length) + |> assign(:min_route_max_jumps, @min_route_max_jumps) + |> assign(:max_route_max_jumps, @max_route_max_jumps) + |> assign(:corp_min_search_length, CorporationSearch.min_search_length()) + |> assign_new(:system_options, fn -> [] end) + |> assign_new(:system_search_error, fn -> nil end) + |> assign_new(:home_system_search_error, fn -> nil end) + |> assign_new(:home_system_error, fn -> nil end) + |> assign_new(:corp_options, fn -> [] end) + |> assign_new(:corp_search_error, fn -> nil end) + |> assign_new(:message, fn -> nil end) + # Which disclosures are open. `assign_new`, so the set survives every + # `update/2` the parent triggers โ€” a save, a PubSub tick or a background + # channel-identity refresh must not close a section the operator is + # working inside. + |> assign_new(:open_sections, fn -> MapSet.new() end) + # Labels are decoration accumulated from whatever Discord has answered so + # far, and they deliberately survive `assign_notification/2`: a chip that + # reads "Scouts" must not flip back to a raw snowflake because the + # operator saved an unrelated field. Ids are the state; see the mention + # helpers below. + |> assign_new(:mention_labels, fn -> %{} end) + |> assign_new(:guild_roles, fn -> nil end) + |> assign_new(:mention_user_options, fn -> [] end) + |> assign_new(:mention_role_options, fn -> [] end) + |> assign_new(:mention_search_error, fn -> nil end) + |> assign_new(:mention_error, fn -> nil end) + |> assign_notification(notification)} + end + + # --- Kill switches (`#kill-toggles-form`) ------------------------------- + + # The two kill switches apply on change, with no Save of their own. They are + # independent booleans with nothing to validate them against, they render as + # switches (`.input type="checkbox"` is a PrimeReact switch in this app), and + # the chip lists beside them โ€” excluded systems, focus corporations, mention + # targets โ€” have always saved immediately. A Save button for two switches was + # the third of the five Saves the tab used to carry. + # + # The route toggle is deliberately NOT in here: it is validated against + # `home_system_id`, so it commits together with the home system and the jump + # limit through the Save inside the route section. See `save-settings`. + @impl true + def handle_event("toggle-setting", %{"notification" => params}, socket) do + case upsert(socket, notification_attrs(params)) do + {:ok, rec} -> + {:noreply, assign_notification(socket, rec)} + + {:error, error} -> + # Re-assigning the unchanged record is what puts the switch back where + # it was: a checkbox renders `checked` from its form value, so + # rebuilding the form from the record reverts the optimistic flip the + # browser already painted. + {:noreply, + socket + |> assign_notification(socket.assigns.notification) + |> put_message(:error, humanize_error(error))} + end + end + + # --- Route alerts and their Save (`#notification-settings-form`) --------- + + def handle_event("save-settings", %{"notification" => params}, socket) do + case resolve_home_system(params) do + {:ok, params} -> + case upsert(socket, notification_attrs(params)) do + {:ok, rec} -> + {:noreply, + socket + |> assign_notification(rec) + |> assign(:home_system_error, nil) + |> put_message(:info, "Saved.")} + + {:error, error} -> + {:noreply, put_message(socket, :error, humanize_error(error))} + end + + {:error, message} -> + {:noreply, put_home_system_error(socket, message)} + end + end + + # Three jobs on every change of the settings form: keep `route_toggle` (which + # fields below are enabled โ€” D4, disable rather than hide) in step with the + # unsaved checkbox state, rebuild the form so the tick survives the + # round-trip (#130 โ€” a checkbox renders `checked` from its form value, so + # re-rendering against the unchanged form patches the user's tick back out + # and Save then posts `false`), and keep the Save button's dirty gate honest. + # All three are purely client-display; the persisted values only ever change + # through "save-settings". + # + # Rebuilt from `params` rather than by patching one key, so anything already + # typed into `home_system_id` / `route_max_jumps` is preserved instead of + # being reset to the last-saved record on every tick. This form carries no + # `webhook_url`, so #130's credential-blanking step has nothing to do here. + def handle_event("settings-change", %{"notification" => params}, socket) do + rec = socket.assigns.notification + + dirty? = + changed?(params, "route_alerts_enabled", current_route_alerts?(rec), &checked?/1) or + changed?(params, "home_system_id", current_home_system_id(rec), &parse_home_system_id/1) or + changed?( + params, + "route_max_jumps", + current_route_max_jumps(rec), + &parse_route_max_jumps/1 + ) + + {:noreply, + socket + |> assign(:route_toggle, checked?(params["route_alerts_enabled"])) + |> assign(:settings_form, rebuild_form(params, route_form_values(rec))) + |> assign(:dirty, dirty?)} + end + + # --- Webhook destinations (system / character / route rows) ------------ + + def handle_event("toggle-section", %{"section" => section}, socket) do + open = socket.assigns.open_sections + + open = + if MapSet.member?(open, section), + do: MapSet.delete(open, section), + else: MapSet.put(open, section) + + {:noreply, assign(socket, :open_sections, open)} + end + + def handle_event("replace-url", %{"role" => role}, socket) do + {:noreply, put_replacing(socket, parse_role(role), true)} + end + + # Undoes "replace-url" without saving anything. Before this existed, a + # misclick on Edit/Replace discarded the masked view with no way back short + # of re-pasting a credential the user does not have on hand, or closing and + # reopening the whole dialog. + def handle_event("cancel-replace", %{"role" => role}, socket) do + {:noreply, put_replacing(socket, parse_role(role), false)} + end + + # Creates the policy row on demand. Any destination can be the first one + # configured on a map โ€” including `:route`, which is the whole point of the + # resource's `webhook_url` argument having become optional. Before that, the + # only way to reach this screen's route half was to configure a kill channel + # you might not want. + def handle_event("save-webhook", %{"role" => role, "webhook" => params}, socket) do + role = parse_role(role) + + case ensure_notification(socket) do + {:ok, rec} -> + case save_webhook(rec, socket.assigns.webhooks[role], role, params) do + {:ok, _} -> + {:noreply, + socket + |> assign_notification(reload_notification(socket.assigns.map_id)) + |> put_message(:info, "Saved.")} + + {:error, error} -> + # `:notification` is assigned even though the save failed, because + # by this point the policy row is committed regardless. Leaving it + # nil made `ensure_notification/1` try to create it a SECOND time + # when the operator corrected the URL and resubmitted, and the + # `unique_map_id` identity rejects that โ€” so a rejected URL used to + # poison every retry with "has already been taken". + # + # Only that one key, not `assign_notification/2`: the full cascade + # rebuilds `webhook_forms` and `replacing_url?`, which would close + # the row and discard the URL the operator is in the middle of + # fixing. + {:noreply, + socket + |> assign(:notification, rec) + |> put_message(:error, humanize_error(error))} + end + + {:error, error} -> + {:noreply, put_message(socket, :error, humanize_error(error))} + end + end + + def handle_event("remove-webhook", %{"role" => role}, socket) do + # All three destinations are removable. `:system` used to be exempt on the + # grounds that "removing notifications entirely means deleting the parent + # record" โ€” true while a pane-level destroy existed and while `create` + # required a kill webhook, and false now that neither does. Leaving it + # exempt would have made the kill channel the one destination on the tab + # that could be added but never taken away. + # + # Removing `:character` falls back to `:system`; removing `:route` does + # NOT โ€” route alerts stop entirely, because the Router deliberately has no + # fallback for chain topology. Removing `:system` stops kill delivery for + # systems on the map, and `Router.usable/1` drops the destination rather + # than routing elsewhere. + role = parse_role(role) + + case {role, socket.assigns.webhooks[role]} do + {role, %{} = webhook} when role in [:system, :character, :route] -> + case MapDiscordWebhook.destroy(webhook) do + :ok -> + {:noreply, + socket + |> assign_notification(reload_notification(socket.assigns.map_id)) + |> put_message(:info, "#{role_label(role)} removed.")} + + {:error, error} -> + {:noreply, put_message(socket, :error, humanize_error(error))} + end + + _ -> + {:noreply, put_message(socket, :error, "That destination is not configured.")} + end + end + + # --- Mention targets --------------------------------------------------- + # + # Four events, mirroring `add-excluded` / `remove-excluded` exactly: each one + # rewrites both lists and saves immediately, so there is no unsaved mention + # state and no dirty gate to reason about. + + def handle_event("add-mention-user", %{"mention_user" => %{"mention_user" => raw}}, socket) do + add_mention(socket, :user, raw) + end + + def handle_event("add-mention-role", %{"mention_role" => %{"mention_role" => raw}}, socket) do + add_mention(socket, :role, raw) + end + + # The D7 manual fallback. Same validation, same save โ€” the only difference is + # where the id came from, which is why it converges on `add_mention/3` rather + # than carrying its own path to the resource. + def handle_event("add-mention-id", %{"kind" => kind, "mention_id" => %{"value" => raw}}, socket) do + add_mention(socket, mention_kind(kind), raw) + end + + def handle_event("remove-mention", %{"kind" => kind, "id" => id}, socket) do + case mention_kind(kind) do + :user -> + save_mentions( + socket, + List.delete(socket.assigns.mention_users, id), + socket.assigns.mention_roles + ) + + :role -> + save_mentions( + socket, + socket.assigns.mention_users, + List.delete(socket.assigns.mention_roles, id) + ) + end + end + + # LiveSelect's search callback: users know systems and corporations by name, + # not by numeric id. + # + # This must be handled HERE and not by the parent LiveView, whose own + # `live_select_change` handler answers unconditionally with access-list + # options. `phx-target={@myself}` on each live_select is what keeps the event + # in this component. + # + # Three pickers share this handler, so it MUST dispatch on the id that + # fired. Answering unconditionally with system options would fill the + # corporation dropdown with solar systems. + def handle_event("live_select_change", %{"id" => @focus_corp_select_id, "text" => text}, socket) do + {options, corp_search_error} = search_corporations(socket.assigns[:current_user], text) + + send_update(LiveSelect.Component, id: @focus_corp_select_id, options: options) + + {:noreply, + socket + |> assign(:corp_options, options) + |> assign(:corp_search_error, corp_search_error)} + end + + # The home-system picker searches the same way the excluded-systems one does + # but keeps its own options and error assigns, so a failed lookup is reported + # under the box the user is typing in rather than under the other one. It also + # clears the "no system by that name" error from the previous save attempt: + # the user is now picking from the list, which is the fix for it. + def handle_event( + "live_select_change", + %{"id" => @home_system_select_id, "text" => text}, + socket + ) do + {options, home_system_search_error} = search_systems(text) + options = maybe_prepend_numeric_system(options, text) + + send_update(LiveSelect.Component, id: @home_system_select_id, options: options) + + {:noreply, + socket + |> assign(:home_system_options, options) + |> assign(:home_system_search_error, home_system_search_error) + |> assign(:home_system_error, nil)} + end + + # Roles are filtered in memory: `Guild.roles/1` already returned the whole + # list, so a keystroke here is a `String.contains?` rather than a request. + def handle_event( + "live_select_change", + %{"id" => @mention_role_select_id, "text" => text}, + socket + ) do + options = + case socket.assigns.guild_roles do + {:ok, roles} -> role_options(roles, text) + _unavailable -> [] + end + + send_update(LiveSelect.Component, id: @mention_role_select_id, options: options) + + {:noreply, assign(socket, :mention_role_options, options)} + end + + # Members, unlike roles, cannot be listed โ€” the search IS the lookup, so this + # one does go to Discord per keystroke (debounced by the picker). + def handle_event( + "live_select_change", + %{"id" => @mention_user_select_id, "text" => text}, + socket + ) do + {options, error} = search_members(socket.assigns[:mention_guild_id], text) + + send_update(LiveSelect.Component, id: @mention_user_select_id, options: options) + + {:noreply, + socket + |> assign(:mention_user_options, options) + |> assign(:mention_search_error, error) + |> learn_labels(:user, member_entries(options))} + end + + def handle_event("live_select_change", %{"id" => id, "text" => text}, socket) do + {options, system_search_error} = search_systems(text) + + send_update(LiveSelect.Component, id: id, options: options) + + {:noreply, + socket + |> assign(:system_options, options) + |> assign(:system_search_error, system_search_error)} + end + + def handle_event("add-excluded", %{"excluded" => %{"excluded_system" => raw}}, socket) do + with %{} = rec <- socket.assigns.notification, + {id, ""} <- Integer.parse(to_string(raw)) do + update_excluded(socket, rec, Enum.uniq([id | rec.excluded_systems])) + else + _ -> {:noreply, put_message(socket, :error, "Pick a system from the list.")} + end + end + + # Guarded the same way as `add-excluded`: only reachable from a rendered + # button today, but the two handlers should not disagree about whether a + # missing record or a non-numeric id is survivable. + def handle_event("remove-excluded", %{"system_id" => raw}, socket) do + with %{} = rec <- socket.assigns.notification, + {id, ""} <- Integer.parse(to_string(raw)) do + update_excluded(socket, rec, Enum.reject(rec.excluded_systems, &(&1 == id))) + else + _ -> {:noreply, put_message(socket, :error, "Could not remove that system.")} + end + end + + # Guarded the same way as `add-excluded`: only reachable from a rendered + # record, and only for an id that parses cleanly. LiveSelect hands back the + # corporation eve id as a STRING (`character.ex:365`), while `focus_corp_ids` + # stores integers. + def handle_event("add-focus-corp", %{"focus_corp" => %{"focus_corp" => raw}}, socket) do + with %{} = rec <- socket.assigns.notification, + {id, ""} <- Integer.parse(to_string(raw)) do + update_focus_corps(socket, rec, Enum.uniq(rec.focus_corp_ids ++ [id])) + else + _ -> {:noreply, put_message(socket, :error, "Pick a corporation from the list.")} + end + end + + def handle_event("remove-focus-corp", %{"corp_id" => raw}, socket) do + with %{} = rec <- socket.assigns.notification, + {id, ""} <- Integer.parse(to_string(raw)) do + update_focus_corps(socket, rec, Enum.reject(rec.focus_corp_ids, &(&1 == id))) + else + _ -> {:noreply, put_message(socket, :error, "Could not remove that corporation.")} + end + end + + def handle_event("send-test", %{"webhook_id" => webhook_id}, socket) do + case send_test(socket, webhook_id) do + # `:ok` means the message was ENQUEUED โ€” the last hop is an async cast, so + # this must not claim Discord accepted it. + :ok -> + {:noreply, put_message(socket, :info, "Test message queued.")} + + {:error, :notifications_disabled} -> + {:noreply, + put_message( + socket, + :error, + "Discord notifications are disabled on this server. Ask an administrator to enable them." + )} + + {:error, :webhook_disabled} -> + {:noreply, + put_message( + socket, + :error, + "This destination is disabled. Enable it and save before sending a test message." + )} + + # Two distinct dispatcher answers, one message on purpose: "no such row" + # and "row with no usable URL" are worth telling apart in a log or a test, + # but a user can do nothing about either except save a URL, and this + # wording is brief-mandated verbatim. + {:error, reason} when reason in [:webhook_not_found, :webhook_url_missing] -> + {:noreply, put_message(socket, :error, "Save a webhook URL first.")} + + {:error, other} -> + # `inspect(other)` here put the raw failure term โ€” which can carry the + # webhook URL โ€” into the LiveView diff. Log a shape summary instead. + Logger.warning("[MapNotifications] test message failed: #{error_summary(other)}") + + {:noreply, + put_message( + socket, + :error, + "Could not send a test message. Check the webhook URL and try again." + )} + end + end + + # There is deliberately no "remove everything" event. Each destination has its + # own Remove, and a policy row with no destinations delivers nothing โ€” so the + # only thing a wholesale destroy did that the per-row removes do not was + # discard invisible filter and mention state, at the cost of parking the + # tab's most destructive control in a permanent action bar. + + # One message region for the whole pane, at its foot. It used to be two + # (`scope: :kills | :route`), which only made sense while the tab had a Save + # per section and the two sections were far enough apart that a result had to + # land next to the control that produced it. + defp put_message(socket, kind, text) do + assign(socket, :message, %{kind: kind, text: text}) + end + + # `webhook_id` arrives from the client as `phx-value-webhook_id`, and + # `send_test_message/1` resolves it by id alone โ€” across every map in the + # installation. So the id MUST be matched against this map's own destinations + # before it is dispatched; without that, anyone who can open a settings tab + # can post the test message into any other map's Discord channel. + # + # The `enabled?` check that rides along STAYS even though `send_test_message/1` + # now reports the disabled case itself, and the ordering is the reason. The + # dispatcher checks the global kill-switch first, so with webhooks disabled + # server-wide it answers `:notifications_disabled` and never looks at the row โ€” + # a user who unticked one destination would be told the whole server is off. + # Checking here keeps the more specific message. `map_notifications_test.exs` + # fails if either half of this is removed. + defp send_test(socket, webhook_id) do + socket.assigns.webhooks + |> Map.values() + |> Enum.find(&match?(%{id: ^webhook_id}, &1)) + |> case do + nil -> {:error, :webhook_not_found} + %{enabled?: false} -> {:error, :webhook_disabled} + _webhook -> WandererApp.ExternalEvents.DiscordDispatcher.send_test_message(webhook_id) + end + end + + # `mention_targets` is deliberately absent from every branch here. It left + # this form in D3 and is now chip state saved by its own events โ€” passing it + # would mean reading a field the form no longer renders, i.e. `nil`, i.e. + # every URL edit silently wiping the map's configured pings. + defp save_webhook(rec, nil, role, %{"webhook_url" => url}) + when is_binary(url) and url != "" do + MapDiscordWebhook.create(%{ + notification_id: rec.id, + role: role, + webhook_url: url + }) + end + + defp save_webhook(_rec, nil, _role, _params), do: {:error, "Enter a webhook URL first."} + + defp save_webhook(_rec, webhook, _role, %{"webhook_url" => url} = params) + when is_binary(url) and url != "" do + MapDiscordWebhook.update(webhook, %{ + webhook_url: url, + enabled?: checked?(params["enabled"]) + }) + end + + # This branch used to call `MapDiscordWebhook.set_enabled/2`, whose accept + # list is `[:enabled?]` only. It goes through the general `update` action + # instead โ€” `set_enabled` itself is untouched and still used by other callers + # (see `router_test.exs`, `worker_test.exs`), this is only this handler's own + # dispatch. + defp save_webhook(_rec, webhook, _role, params) do + MapDiscordWebhook.update(webhook, %{enabled?: checked?(params["enabled"])}) + end + + # Every write on this tab funnels through here: the policy row is created + # lazily by whichever control the operator touched first, with no webhook + # attached. `create`'s `webhook_url` argument is optional for exactly this + # (map_discord_notification.ex) โ€” the destinations are then added through + # "save-webhook" like any later one. + defp upsert(socket, attrs) do + case socket.assigns.notification do + nil -> attrs |> Map.put(:map_id, socket.assigns.map_id) |> MapDiscordNotification.create() + rec -> MapDiscordNotification.update(rec, attrs) + end + end + + defp ensure_notification(socket) do + case socket.assigns.notification do + nil -> MapDiscordNotification.create(%{map_id: socket.assigns.map_id}) + rec -> {:ok, rec} + end + end + + # Record-or-default readers, so the dirty gate and the form values work the + # same before and after the policy row exists. Each mirrors the resource's + # own default for the attribute. + defp current_route_alerts?(nil), do: false + defp current_route_alerts?(%{route_alerts_enabled?: value}), do: value + + defp current_home_system_id(nil), do: nil + defp current_home_system_id(%{home_system_id: value}), do: value + + defp current_route_max_jumps(nil), do: @default_route_max_jumps + defp current_route_max_jumps(%{route_max_jumps: value}), do: value + + defp put_replacing(socket, role, value) do + assign(socket, :replacing_url?, Map.put(socket.assigns.replacing_url?, role, value)) + end + + # Mirrors `Router.usable/1`, which drops on BOTH a missing `:route` row and a + # configured-but-disabled one. Scoping the hint to the missing-row case only + # would leave the disabled case as the same silent dead end, and disabling a + # destination is a click โ€” much easier to do by accident than never creating + # one. + defp route_destination_ready?(nil), do: false + defp route_destination_ready?(%{enabled?: enabled?}), do: enabled? + + defp parse_role("character"), do: :character + defp parse_role(:character), do: :character + defp parse_role("route"), do: :route + defp parse_role(:route), do: :route + defp parse_role(_), do: :system + + # One clause per role, and every role has one. `:system` was missing while + # `remove-webhook` still refused that role; widening the handler without + # widening this made removing the kill channel raise FunctionClauseError โ€” + # after the destroy had already committed, so the row was gone and the tab + # was down. + # + # The strings are the row titles verbatim, so the confirmation names the + # thing the operator just clicked ("Kill channel removed.") rather than a + # role name that appears nowhere on screen ("System destination removed."). + defp role_label(:system), do: "Kill channel" + defp role_label(:character), do: "Character kill channel" + defp role_label(:route), do: "Route alert channel" + + defp reload_notification(map_id) do + case MapDiscordNotification.by_map(map_id) do + {:ok, rec} -> rec + _ -> nil + end + end + + defp update_excluded(socket, rec, excluded) do + case MapDiscordNotification.update(rec, %{excluded_systems: excluded}) do + {:ok, updated} -> + {:noreply, assign_notification(socket, updated)} + + {:error, error} -> + {:noreply, put_message(socket, :error, humanize_error(error))} + end + end + + defp update_focus_corps(socket, rec, corp_ids) do + case MapDiscordNotification.update(rec, %{focus_corp_ids: corp_ids}) do + {:ok, updated} -> + {:noreply, assign_notification(socket, updated)} + + {:error, error} -> + {:noreply, put_message(socket, :error, humanize_error(error))} + end + end + + # D1 โ€” the single most load-bearing change in this rework. Two independent + # callers need "absent key means keep current, never clear": + # + # * The route settings form's fields are `disabled={not @route_toggle}` + # rather than hidden (D4) โ€” but a rendered-and-disabled input still + # submits NOTHING, same as an unrendered one. The old blanket + # `params["home_system_id"]` read would parse that absence as `nil` and + # wipe a saved home system on every Save made with route alerts off. + # * L0's create form has no `wh_only`/`enabled` checkboxes at all (moved to + # L1). `checked?(nil)` is `false`, which is exactly the regression this + # guards against โ€” every new config being born disabled with no visible + # control to contradict it. + # + # Present-but-blank still clears: that is the user emptying a field, which + # is a different thing from the field never having been on the page. + defp notification_attrs(params) do + %{} + |> put_param(params, "wh_only", :wh_only, &checked?/1) + |> put_param(params, "enabled", :enabled?, &checked?/1) + |> put_param(params, "route_alerts_enabled", :route_alerts_enabled?, &checked?/1) + |> put_param(params, "home_system_id", :home_system_id, &parse_home_system_id/1) + |> put_param(params, "route_max_jumps", :route_max_jumps, &parse_route_max_jumps/1) + end + + defp put_param(attrs, params, key, attr, parse) do + if Map.has_key?(params, key), do: Map.put(attrs, attr, parse.(params[key])), else: attrs + end + + defp changed?(params, key, current, parse), + do: Map.has_key?(params, key) and parse.(params[key]) != current + + # Resolves excluded-system names and focus-corporation labels once per change, + # not once per render: both run lookups, and the template re-renders on every + # live_select keystroke. Also rebuilds every form so values follow the record, + # and resets the purely-client dirty/mention-error state: a freshly (re)loaded + # record is by definition not dirty against itself, and any mention-field + # complaint was about input that either just got saved or just got replaced. + defp assign_notification(socket, notification) do + webhooks = load_webhooks(notification) + + socket + |> assign(:notification, notification) + |> assign(:webhooks, webhooks) + |> assign(:collisions, ChannelInfo.colliding_roles(webhooks)) + |> assign(:route_toggle, current_route_alerts?(notification)) + |> assign(:dirty, false) + |> assign(:excluded_systems, excluded_system_labels(notification)) + |> assign(:focus_corps, focus_corp_labels(notification)) + |> assign(:home_system_options, home_system_options(notification)) + |> assign(:kills_form, kills_form(notification)) + |> assign(:settings_form, route_form(notification)) + |> assign(:webhook_forms, webhook_forms(webhooks)) + |> assign(:excluded_form, to_form(%{"excluded_system" => nil}, as: :excluded)) + |> assign(:focus_corp_form, to_form(%{"focus_corp" => nil}, as: :focus_corp)) + |> assign_replacing(webhooks) + |> assign_channel_hints(webhooks) + |> assign_mentions(webhooks) + |> request_guild_roles() + end + + ## Mention targets (D3) -------------------------------------------------- + # + # Stored as `["user:", "role:", ...]` on the route webhook. On screen + # they are two independent chip lists edited by discrete events that save + # immediately, exactly like excluded systems and focus corporations โ€” not a + # form field, so they are outside the dirty gate and cannot be wiped by a + # save that never rendered them. + # + # **The id is the state; the label is decoration.** The chip lists here are + # ids only, split out of what is stored, with no lookup involved. That is the + # property that makes a target impossible to lose: recombination writes the + # same ids back, so an entry whose name was never resolved round-trips + # byte-identically instead of being silently dropped for lacking one. + + defp assign_mentions(socket, webhooks) do + route = Map.get(webhooks, :route) + targets = (route && route.mention_targets) || [] + + socket + |> assign(:mention_users, mention_ids(targets, "user")) + |> assign(:mention_roles, mention_ids(targets, "role")) + |> assign_mention_guild(route) + |> assign(:mention_error, nil) + end + + # The cached hint is preferred over the stored column because it is the + # fresher of the two: a background `ChannelInfo` refresh writes the row and + # warms the cache, but the record already in this socket predates it. Reading + # the hint is what lets the pickers come alive on the same refresh that names + # the channel, instead of on the next full reload. + defp assign_mention_guild(socket, route) do + guild_id = + case socket.assigns[:channel_hints][:route] do + %{guild_id: guild_id} when is_binary(guild_id) -> guild_id + _no_hint -> route && route.guild_id + end + + assign(socket, :mention_guild_id, guild_id) + end + + defp mention_ids(targets, prefix) do + for target <- targets, + [^prefix, id] <- [String.split(target, ":", parts: 2)], + do: id + end + + # Fetches the guild's roles off the render path, for the same reason + # `ChannelInfo.describe/2` refuses to block: this is an HTTP call with a + # multi-second leash, and the settings dialog must open now. The reply comes + # back through `MapsLive` as `{:discord_guild_roles, guild_id, result}` โ€” a + # three-tuple for the same reason the channel-refresh message is one. + # + # Requested once per guild. `assign_notification/2` runs on every save, and + # re-asking each time would put a request behind every button on the tab. + defp request_guild_roles(socket) do + guild_id = socket.assigns[:mention_guild_id] + pid = self() + + cond do + is_nil(guild_id) -> + assign(socket, :guild_roles, {:error, :no_guild}) + + socket.assigns[:guild_roles_requested_for] == guild_id -> + socket + + true -> + Task.Supervisor.start_child(WandererApp.TaskSupervisor, fn -> + send(pid, {:discord_guild_roles, guild_id, Guild.roles(guild_id)}) + end) + + socket + |> assign(:guild_roles_requested_for, guild_id) + |> assign(:guild_roles, :loading) + end + end + + defp learn_role_labels(socket, {:ok, roles}) do + learn_labels(socket, :role, roles) + end + + defp learn_role_labels(socket, _result), do: socket + + defp learn_labels(socket, kind, entries) do + labels = + Enum.reduce(entries, socket.assigns.mention_labels, fn %{id: id, name: name}, acc -> + Map.put(acc, {kind, id}, name) + end) + + assign(socket, :mention_labels, labels) + end + + # Whether the typeahead can work at all. Both pickers need the same bot token + # and the same guild membership, so one signal drives both: a guild we cannot + # read roles from is one we cannot search members in either, and asking the + # operator to discover that by typing into a dropdown that stays empty is + # exactly the failure D7 exists to prevent. + defp mention_picker_available?(%{guild_roles: {:error, reason}}), + do: not Guild.unavailable?(reason) + + defp mention_picker_available?(_assigns), do: true + + # Every one of these is read by an operator who is mid-task and wants to know + # whether they can proceed. So each says what still works (typing ids always + # does), and the one with an actionable cause names it: searching by name + # needs a bot token, `DISCORD_BOT_TOKEN`, set on the server โ€” which is a + # deployment change, not something this screen can offer a button for. + defp mention_unavailable_reason(%{guild_roles: {:error, :no_bot_token}}), + do: + "Searching Discord names needs a bot token (DISCORD_BOT_TOKEN) on the server, and this " <> + "one has none. Mentions still work โ€” paste the user or role id instead." + + defp mention_unavailable_reason(%{guild_roles: {:error, :no_guild}}), + do: + "The Discord server behind this channel is not known yet. It resolves once the bot can " <> + "see the channel; until then, paste ids instead." + + defp mention_unavailable_reason(%{guild_roles: {:error, _reason}}), + do: "The bot cannot read this Discord server, so names cannot be searched. Paste ids instead." + + defp mention_unavailable_reason(_assigns), do: nil + + # Chip text. A target whose name was never resolved renders as its raw id + # rather than being hidden โ€” it is saved, it pings, and it must be removable. + defp mention_label(labels, kind, id) do + case Map.get(labels, {kind, id}) do + nil -> id + name -> name + end + end + + defp mention_labelled?(labels, kind, id), do: Map.has_key?(labels, {kind, id}) + + # Takes a bare snowflake from the manual fallback and validates it through + # the same `Mentions.valid_target?/1` the dispatcher uses, without asking the + # operator to retype a `user:`/`role:` prefix the input they typed into + # already implies. + defp add_mention(socket, kind, raw) do + case parse_mention_id(kind, raw) do + {:ok, id} -> + users = socket.assigns.mention_users + roles = socket.assigns.mention_roles + + case kind do + :user -> save_mentions(socket, Enum.uniq(users ++ [id]), roles) + :role -> save_mentions(socket, users, Enum.uniq(roles ++ [id])) + end + + {:error, message} -> + {:noreply, assign(socket, :mention_error, message)} + end + end + + defp mention_kind("role"), do: :role + defp mention_kind(_kind), do: :user + + defp parse_mention_id(kind, raw) when is_binary(raw) do + target = "#{kind}:#{String.trim(raw)}" + + if Mentions.valid_target?(target) do + {:ok, String.trim(raw)} + else + {:error, + "That is not a Discord id. Copy the numeric id (17-20 digits) from Discord โ€” " <> + "handles like @name do not work here."} + end + end + + defp parse_mention_id(_kind, _raw), do: {:error, "Enter a Discord id."} + + # Writes both lists back as one `mention_targets` value. Ids only: nothing + # here can consult a label, so nothing here can drop a target for missing + # one. + defp save_mentions(socket, users, roles) do + case socket.assigns.webhooks[:route] do + nil -> + {:noreply, + put_message(socket, :error, "Add a route alert channel before setting mentions.")} + + webhook -> + targets = + Enum.map(users, &"user:#{&1}") ++ Enum.map(roles, &"role:#{&1}") + + case MapDiscordWebhook.update(webhook, %{mention_targets: targets}) do + {:ok, _updated} -> + {:noreply, + socket + |> assign_notification(reload_notification(socket.assigns.map_id)) + |> assign(:mention_error, nil)} + + {:error, error} -> + {:noreply, put_message(socket, :error, humanize_error(error))} + end + end + end + + # Resolved into an assign rather than called from the template, for two + # reasons. The template re-renders on every typeahead keystroke and this is a + # cache read per destination each time; and, more importantly, an assign is + # something LiveView's change tracking can see โ€” a hint computed inside the + # template depends only on `@webhook`, so a background refresh landing would + # update the cache and change nothing on screen. + # + # `notify: self()` is the other half: inside a LiveComponent callback `self()` + # is the parent LiveView's pid, which is what routes the refresh back through + # `MapsLive` to `send_update/3`. + defp assign_channel_hints(socket, webhooks) do + hints = + Map.new(@roles, fn role -> + {role, describe_channel(Map.get(webhooks, role))} + end) + + assign(socket, :channel_hints, hints) + end + + defp describe_channel(nil), do: nil + + defp describe_channel(webhook) do + case ChannelInfo.describe(webhook, notify: self()) do + {:ok, info} -> info + {:error, _reason} -> nil + end + end + + # A destination is in entry mode only when the operator asked for it, via + # "Add" (no webhook yet) or "Edit" (replacing a stored URL). It used to open + # automatically for every unconfigured role, which was invisible while the + # character and route rows were themselves hidden behind a link and a + # disclosure. Now that all three rows always render, auto-opening would put + # three credential fields on screen before the operator has asked for one. + defp assign_replacing(socket, _webhooks) do + assign(socket, :replacing_url?, Map.new(@roles, &{&1, false})) + end + + defp load_webhooks(nil), do: %{system: nil, character: nil, route: nil} + + defp load_webhooks(%{id: notification_id}) do + records = + case MapDiscordWebhook.by_notification(notification_id) do + {:ok, list} -> list + _ -> [] + end + + Map.new(@roles, fn role -> {role, Enum.find(records, &(&1.role == role))} end) + end + + # Rebuilds a form from submitted params WITHOUT losing fields the payload + # did not mention. + # + # #130 established that these change handlers must rebuild the form at all + # (a checkbox renders `checked` from its form value, so re-rendering against + # the unchanged form patches the user's tick back out). Rebuilding straight + # from `params` then introduces the opposite failure: a payload carrying + # only the field that changed blanks every other field in the form, and the + # next Save posts those blanks. + # + # A real browser serialises the whole form even for an input-level + # `phx-change`, so in production the merge is a no-op โ€” including for + # unticking, where the hidden "false" companion keeps the key present. It + # matters for any partial payload, and it means the base values fall back to + # the persisted record rather than to empty. + # + # `webhook_url` is forced back to "" last, keeping #130's defence even + # though no form that carries a credential has a `phx-change` any more: the + # generic `.input` writes `value=` for password inputs too, so a submitted + # URL reaching `to_form/2` would be printed into the server-rendered HTML. + defp rebuild_form(params, base) do + base + |> Map.merge(params) + |> to_form(as: :notification) + end + + defp kills_form(notification), do: to_form(kills_form_values(notification), as: :notification) + + # No `webhook_url` here any more. It existed for the old "no record yet" + # create form, which asked for a kill webhook before the tab would show + # anything else. Destinations are now added through their own rows, at any + # time, in any order. + defp kills_form_values(notification) do + %{ + "enabled" => is_nil(notification) or notification.enabled?, + "wh_only" => is_nil(notification) or notification.wh_only + } + end + + defp route_form(notification), do: to_form(route_form_values(notification), as: :notification) + + defp route_form_values(notification) do + %{ + # Unlike wh_only/enabled, this one defaults OFF (Task 3: `default: + # false`) โ€” `is_nil(notification) or ...` would default it ON, which + # is backwards for this field. + "route_alerts_enabled" => current_route_alerts?(notification), + "home_system_id" => home_system_id_value(notification), + "route_max_jumps" => current_route_max_jumps(notification) + } + end + + defp home_system_id_value(nil), do: "" + defp home_system_id_value(%{home_system_id: nil}), do: "" + defp home_system_id_value(%{home_system_id: id}), do: to_string(id) + + # Blank or non-numeric input clears the home system rather than raising โ€” + # the Ash "required when enabled" validation is what reports that, not this + # parse step, matching how `add-excluded`/`add-focus-corp` already leave + # rejection to a later stage rather than crashing on bad input here. + defp parse_home_system_id(raw) do + case Integer.parse(to_string(raw || "")) do + {id, ""} -> id + _ -> nil + end + end + + # What the form posts for the home system, resolved to the integer id the + # resource stores. Returns the params back with `home_system_id` rewritten, + # so `notification_attrs/1` keeps being the single place that decides which + # attributes a save touches. + # + # LiveSelect submits two inputs: the hidden `home_system_id` (the picked + # option's value) and the visible `home_system_id_text_input`. Normally only + # the first matters. The text is the fallback for the user who types a full + # name and hits Save without opening the dropdown โ€” resolving it here is what + # keeps that from posting `nil` and coming back as the generic "is required + # when route alerts are enabled", which says nothing about the name they + # typed. + # + # With route alerts off the picker sits inside a `disabled` fieldset, so it + # submits NOTHING โ€” neither input is in `params`. That absence is D1's + # "keep the current value", and it must not be turned into a nil here: it is + # exactly what stops Save-with-alerts-off from wiping a saved home system. + defp resolve_home_system(params) when not is_map_key(params, "home_system_id"), + do: {:ok, params} + + defp resolve_home_system(params) do + case parse_home_system_id(params["home_system_id"]) do + id when is_integer(id) -> + {:ok, Map.put(params, "home_system_id", id)} + + nil -> + with {:ok, id} <- + resolve_home_system_name( + params["home_system_id_text_input"], + checked?(params["route_alerts_enabled"]) + ) do + {:ok, Map.put(params, "home_system_id", id)} + end + end + end + + defp resolve_home_system_name(raw, true) when is_binary(raw) do + case String.trim(raw) do + "" -> {:ok, nil} + name -> lookup_home_system_name(name) + end + end + + defp resolve_home_system_name(_raw, _route_alerts_enabled?), do: {:ok, nil} + + # `find_by_name` is a substring search (`map_solar_system.ex:104`), so it can + # answer with several systems for a typed prefix. Only an exact name is + # accepted: picking "Jitanenba" for someone who typed "Jita" would silently + # watch the wrong system, and the dropdown is right there for choosing + # between near-misses. + defp lookup_home_system_name(name) do + wanted = String.downcase(name) + + case MapSolarSystem.find_by_name(%{name: name}) do + {:ok, systems} -> + case Enum.find(systems, &(String.downcase(&1.solar_system_name) == wanted)) do + %{solar_system_id: id} -> + {:ok, id} + + nil -> + {:error, + "No solar system is named \"#{name}\". Pick one from the search results below the box."} + end + + other -> + Logger.warning("[MapNotifications] home system lookup failed: #{inspect(other)}") + {:error, @system_search_error} + end + end + + # Field-level, next to the picker: the generic banner at the top of the tab + # is far enough from this box that "no system is named X" reads as being + # about something else. Clears the "Saved." flash for the same reason it + # clears on any other failed save โ€” nothing was written. + defp put_home_system_error(socket, message) do + socket + |> assign(:home_system_error, message) + |> assign(:message, nil) + end + + # Falls back to the column default on blank/non-numeric input rather than + # sending `nil` into an `allow_nil?: false` attribute, which Ash would + # reject outright. + defp parse_route_max_jumps(raw) do + case Integer.parse(to_string(raw || "")) do + {n, ""} -> n + _ -> @default_route_max_jumps + end + end + + # D7's numeric fallback: an all-digits query becomes a usable option even + # when it matches no system name (or matches nothing at all, e.g. an id for + # a system this instance's SDE snapshot doesn't carry). `Integer.parse` + # requiring a full match (`{id, ""}`) is the "all-digits" check โ€” "31k" or + # "J1234" fall through untouched. + # + # The value is a STRING for the reason `search_systems/1` explains: LiveSelect + # re-derives its selection by matching `field.value`, which round-trips + # through the browser as a string. + defp maybe_prepend_numeric_system(options, text) do + case Integer.parse(text) do + {id, ""} -> [{"Solar system #{id}", to_string(id)} | options] + _ -> options + end + end + + defp webhook_forms(webhooks) do + Map.new(@roles, fn role -> + webhook = Map.get(webhooks, role) + + form = + to_form( + %{ + "webhook_url" => "", + "enabled" => is_nil(webhook) or webhook.enabled? + }, + as: :webhook + ) + + {role, form} + end) + end + + # Mirrors the ACL live_select pattern in maps_live: search server-side, feed + # `{label, value}` options back into the component. + # + # Returns `{options, error_message_or_nil}` for the same reason + # `search_corporations/2` does: this is a database lookup that can fail, and an + # empty dropdown reads as "there is no system by that name". + # + # Option VALUES are strings, not the integer solar system ids. LiveSelect + # re-derives its selection by matching `field.value` against its options + # (`component.ex:561-572`), and a form field's value round-trips through the + # browser as a string โ€” so integer values would stop matching the moment the + # form is rebuilt from params and the picker would show a bare id where it had + # shown "Jita (The Forge)". Both consumers parse the value back to an integer + # (`add-excluded`, `resolve_home_system/1`), so nothing downstream cares. + defp search_systems(text) when is_binary(text) and byte_size(text) >= @min_search_length do + case MapSolarSystem.find_by_name(%{name: text}) do + {:ok, systems} -> + {systems + |> Enum.take(@max_search_results) + |> Enum.map(&{system_option_label(&1), to_string(&1.solar_system_id)}), nil} + + other -> + Logger.warning("[MapNotifications] system search failed: #{inspect(other)}") + {[], @system_search_error} + end + end + + defp search_systems(_), do: {[], nil} + + defp system_option_label(%{solar_system_name: name, region_name: region}) + when is_binary(region) and region != "", + do: "#{name} (#{region})" + + defp system_option_label(%{solar_system_name: name}), do: name + + # Seeds the home-system picker with the option it is already showing, so a + # saved home system renders as "Jita (The Forge)" and not as the raw id the + # form field holds. LiveSelect can only put a label on a value it has seen as + # an option, and on the first render its options are whatever this assign + # says (it ignores the assign on later re-renders and carries the selection + # instead, `component.ex:121-127`). + # + # Degrades to the bare id rather than dropping the option, for the same + # reason `focus_corp_labels/1` does: the user must be able to see and change + # what is saved even when the lookup is unavailable. + defp home_system_options(nil), do: [] + defp home_system_options(%{home_system_id: nil}), do: [] + + defp home_system_options(%{home_system_id: id}) do + value = to_string(id) + + case MapSolarSystem.by_solar_system_ids([id]) do + {:ok, [system | _]} -> [{system_option_label(system), value}] + _ -> [{value, value}] + end + end + + # `CorporationSearch.search/3` enforces its own minimum length and returns + # `{:ok, []}` for a user with no characters, so no length guard is needed here. + # + # Returns `{options, error_message_or_nil}`. The distinction matters: this + # lookup leaves the app and can fail for reasons the user can act on (an ESI + # outage, a character whose token needs re-authorising), and an empty dropdown + # is indistinguishable from "that corporation does not exist". Reporting only + # into the log is what made a crash in the token-refresh path present as a + # typeahead that silently did nothing. + # + # The rescue is not belt-and-braces: the search runs as one of the user's + # characters, and `Character.search/2` reaches ESI's token-refresh path. + # Unrescued, a raise there kills the LiveView on a keystroke in the + # corporation box, taking the whole settings tab with it. + defp search_corporations(%{characters: characters}, text) when is_list(characters) do + case CorporationSearch.search(characters, text) do + {:ok, results} -> + {results |> Enum.take(@max_search_results) |> Enum.map(&{&1.formatted, &1.id}), nil} + + {:error, reason} -> + Logger.warning( + "[MapNotifications] corporation search failed as #{inspect(search_character_name(characters))}: #{inspect(reason)}" + ) + + {[], corp_search_error(reason, characters)} + end + rescue + error -> + Logger.warning( + "[MapNotifications] corporation search crashed: #{Exception.format(:error, error, __STACKTRACE__)}" + ) + + {[], corp_search_error(error.__struct__, characters)} + end + + defp search_corporations(_current_user, _text), do: {[], nil} + + # Role search is local โ€” the whole list is already in `@guild_roles`. A blank + # query lists everything, which is the useful behaviour for a guild with a + # handful of roles and the reason this picker has no minimum length. + defp role_options(roles, text) do + wanted = text |> to_string() |> String.trim() |> String.downcase() + + roles + |> Enum.filter(fn %{name: name} -> + wanted == "" or String.contains?(String.downcase(name), wanted) + end) + |> Enum.take(@max_search_results) + |> Enum.map(fn %{id: id, name: name} -> {name, id} end) + end + + # Member search does go to Discord. Failures render next to the box rather + # than only in the log: an empty dropdown is indistinguishable from a guild + # with no matching members, which is the ambiguity D7 exists to remove. + defp search_members(guild_id, text) when is_binary(guild_id) do + case Guild.search_members(guild_id, text, limit: @max_search_results) do + {:ok, members} -> + {Enum.map(members, fn %{id: id, name: name} -> {name, id} end), nil} + + {:error, reason} -> + {[], member_search_error(reason)} + end + end + + defp search_members(_guild_id, _text), do: {[], nil} + + defp member_search_error(reason) do + if Guild.unavailable?(reason) do + "Add the bot to this guild to search names. You can still add ids manually." + else + "Member search is unavailable right now. Try again in a moment. (#{inspect(reason)})" + end + end + + defp member_entries(options), do: Enum.map(options, fn {name, id} -> %{id: id, name: name} end) + + # Log-only; the rendered message goes through `corp_search_error/2`. + defp search_character_name(characters) do + case CorporationSearch.search_character(characters) do + {:ok, %{name: name}} -> name + _ -> nil + end + end + + # One query for every excluded system, not one per system. Falls back to the + # bare id for anything the lookup did not return, and keeps the stored order. + defp excluded_system_labels(nil), do: [] + defp excluded_system_labels(%{excluded_systems: []}), do: [] + + defp excluded_system_labels(%{excluded_systems: ids}) do + labels = + case MapSolarSystem.by_solar_system_ids(ids) do + {:ok, systems} -> + Map.new( + systems, + &{&1.solar_system_id, "#{&1.solar_system_name} (#{&1.solar_system_id})"} + ) + + _ -> + %{} + end + + Enum.map(ids, &{&1, Map.get(labels, &1, to_string(&1))}) + end + + # `label_for/1` already degrades to the bare id, so a chip is never dropped + # because ESI is unreachable โ€” the user must always be able to remove what + # they saved. + defp focus_corp_labels(nil), do: [] + defp focus_corp_labels(%{focus_corp_ids: []}), do: [] + + defp focus_corp_labels(%{focus_corp_ids: ids}), + do: Enum.map(ids, &{&1, CorporationSearch.label_for(&1)}) + + defp checked?("true"), do: true + defp checked?(true), do: true + defp checked?(_), do: false + + defp humanize_error(message) when is_binary(message), do: message + + defp humanize_error(%Ash.Error.Invalid{errors: errors}) do + Enum.map_join(errors, ", ", &error_sentence/1) + end + + defp humanize_error(other), do: fallback_message(other) + + # Ash carries validation copy as a template plus a `vars` bag โ€” a max-length + # violation's `message` is the literal `length must be less than or equal to + # %{max}`. Rendering the raw field shows the user the placeholder, so + # substitute before display. + defp error_sentence(%{message: message} = error) when is_binary(message) do + error + |> Map.get(:vars) + |> List.wrap() + |> Enum.reduce(message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", var_string(value)) + end) + end + + defp error_sentence(other), do: fallback_message(other) + + # Anything without a message is an error shape we did not anticipate. Its + # fields are not user-facing copy, so it is logged rather than rendered โ€” but + # only its TYPE. The struct itself must never be inspected into the log: an + # `Ash.Error.Invalid` raised by a create carries the submitted value in + # `InvalidArgument`/`InvalidAttribute`'s `value:` field, and `sensitive? true` + # on the attribute does NOT redact that โ€” so `inspect/1` here would write the + # webhook URL, a credential, into the log in full. The type is enough to + # identify the shape and add a clause for it. + defp fallback_message(error) do + Logger.warning("[MapNotifications] unrecognised error shape: #{error_summary(error)}") + "Something went wrong. Please try again." + end + + # Struct name for structs, the atom itself for atom reasons (those are code + # constants, never user input), and the bare kind for anything else. None of + # these can carry a submitted value. + defp error_summary(%module{}), do: inspect(module) + defp error_summary(error) when is_atom(error), do: inspect(error) + defp error_summary(error) when is_tuple(error), do: "#{tuple_size(error)}-tuple" + defp error_summary(error) when is_list(error), do: "#{length(error)}-element list" + defp error_summary(error) when is_map(error), do: "plain map" + defp error_summary(_error), do: "unrecognised term" + + defp var_string(value) when is_binary(value), do: value + defp var_string(value) when is_number(value) or is_atom(value), do: to_string(value) + defp var_string(value), do: inspect(value) + + # D6's webhook identity, replacing the old `masked_url/1` (which rendered the + # channel snowflake in full and the first four characters of the token). + # Whichever tier answered, the string is safe to show: a `:channel` label is + # the channel's own "#name", and the fallback is a truncated non-reversible + # digest that is never derived from the token. + # + # The label is spoken plainly, with no "Channel:" / "Webhook:" prefix. Whether + # Discord answered from the channel or from the webhook's own nickname is a + # distinction about how the name was *fetched*, not about where the messages + # land โ€” and the operator has nothing to do differently either way. `source` + # is still persisted (`channel_label_source`) because it separates a masked + # fingerprint from a known name, which is what `masked?` below turns on. + defp channel_label(%{label: label}) when is_binary(label), do: label + defp channel_label(_no_info), do: nil + + # Roles whose destination resolves to the same Discord channel as `role`. + # `ChannelInfo.colliding_roles/1` groups on the resolved `channel_id` where it + # has one, so this catches two DISTINCT webhook URLs pointing at the same + # channel โ€” not merely the same URL pasted twice. + defp collision_partners(collisions, role) do + collisions + |> Enum.find([], &(role in &1)) + |> List.delete(role) + end + + # The route wording is deliberately not the generic one. A route alert names + # every system between the home system and Jita, and the channel's own help + # text asks the map owner to treat it as trusted โ€” so a route channel shared + # with the kill feed is a disclosure, not just untidy configuration. + defp collision_text(:route, partners), + do: + "Route alerts post to the same Discord channel as #{role_names(partners)}. " <> + "Anyone who can read that channel can see this map's home system and the " <> + "route to it." + + defp collision_text(_role, partners), + do: "This channel is also used by #{role_names(partners)} on this map." + + defp role_names(partners), do: partners |> Enum.map(&role_name/1) |> to_sentence() + + defp role_name(:system), do: "the system channel" + defp role_name(:character), do: "the character channel" + defp role_name(:route), do: "route alerts" + + defp to_sentence([one]), do: one + + defp to_sentence(names) do + {rest, [last]} = Enum.split(names, -1) + "#{Enum.join(rest, ", ")} and #{last}" + end + + # --- Status (P1 hierarchy) ----------------------------------------------- + + defp status_state(nil), do: :never + defp status_state(%{enabled?: false}), do: :disabled + + defp status_state(%{last_error: error, consecutive_failures: n}) + when not is_nil(error) and n > 0, + do: :degraded + + defp status_state(%{last_delivery_at: nil}), do: :never + defp status_state(_webhook), do: :delivering + + defp status_label(:delivering), do: "Delivering" + defp status_label(:degraded), do: "Degraded" + defp status_label(:disabled), do: "Disabled" + defp status_label(:never), do: "Never delivered" + + defp status_class(:delivering), do: "text-emerald-400" + defp status_class(:degraded), do: "text-amber-400" + defp status_class(:disabled), do: "text-red-400" + defp status_class(:never), do: "opacity-70" + + defp status_line(nil, _channel_info), do: nil + + # The channel half is dropped rather than rendered empty when the identity is + # not resolved: "Posting to channel ยท no kills yet" reads as a missing value, + # and the identity is genuinely unknown often enough (no bot, a webhook whose + # channel was deleted, a refresh still in flight) for that to be the common + # first impression rather than an edge case. + defp status_line(webhook, channel_info) do + case channel_label(channel_info) do + nil -> last_kill_text(webhook) + destination -> "Posting to #{destination} ยท #{last_kill_text(webhook)}" + end + end + + defp last_kill_text(%{last_delivery_at: nil}), do: "no kills yet" + + defp last_kill_text(%{last_delivery_at: dt}), + do: "last kill #{Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")}" + + # --- Route-alerts P0: the reachable inert warning (D4) ------------------- + + # Alerts ON, no usable channel โ€” the guard that already existed, now + # reachable at any time rather than only while a client-toggled `hidden` + # class happened to be off. + # + # `toggle?` is the UNSAVED checkbox state, and it is ORed with the persisted + # flag on purpose: ticking the box is exactly the moment the map owner needs + # to be told there is no channel to send to, and making them press Save + # first to learn it is the delayed-feedback version of the same bug. + defp route_alert_on_no_channel?(notification, route_webhook, toggle?) do + (toggle? or match?(%{route_alerts_enabled?: true}, notification)) and + not route_destination_ready?(route_webhook) + end + + # Alerts OFF, channel fully configured โ€” the P0 this rework exists for: a + # reachable channel with nothing telling the map owner it is not being used. + defp route_inert?(%{route_alerts_enabled?: false}, route_webhook), + do: route_destination_ready?(route_webhook) + + defp route_inert?(_notification, _route_webhook), do: false + + # --- Disclosure badges (D5) โ€” computed server-side so a client-only toggle + # cannot defeat what the badge reports. ------------------------------------ + + # The only surviving disclosure starts collapsed unconditionally (D2), so the + # badge is the whole signal: it has to report a problem inside the body as + # well as a count, or a failed search is invisible until someone happens to + # open the section. + defp filters_badge(excluded_systems, focus_corps, system_search_error, corp_search_error) do + [ + excluded_count_label(length(excluded_systems)), + focus_corp_count_label(length(focus_corps)), + if(system_search_error || corp_search_error, do: "needs attention") + ] + |> Enum.filter(& &1) + |> case do + [] -> nil + parts -> Enum.join(parts, " ยท ") + end + end + + defp excluded_count_label(0), do: nil + defp excluded_count_label(1), do: "1 system excluded" + defp excluded_count_label(n), do: "#{n} systems excluded" + + defp focus_corp_count_label(0), do: nil + defp focus_corp_count_label(1), do: "1 corporation" + defp focus_corp_count_label(n), do: "#{n} corporations" + + # --- Function components --------------------------------------------- + + # Open/closed lives in `@open_sections` on the server, not in a + # `JS.toggle_class` on the client. The client-only version was the pattern + # copied from characters_live.html.heex, and it is wrong for this panel: every + # save, every background channel-identity refresh and every PubSub-driven + # re-render re-sends this markup with its literal `hidden` and its literal + # `aria-expanded="false"`, slamming an open section shut mid-edit and leaving + # the toggled attribute claiming the opposite of what is on screen. Round-trip + # cost is one small diff; the alternative is state that silently desyncs. + # + # Sections start collapsed. They used to start open whenever the body held a + # problem, which made the initial state depend on transient message state; the + # cause is gone instead โ€” results render in a card-level message region that + # is never collapsed, and `@badge` reports trouble inside the body. + attr :id, :string, required: true + attr :title, :string, required: true + attr :badge, :string, default: nil + attr :open?, :boolean, default: false + attr :myself, :any, required: true + slot :inner_block, required: true + + defp disclosure(assigns) do + ~H""" +
      + + + <%!-- Toggled by class, not by `:if`: `aria-controls` above must resolve + to a real element even while collapsed, and the LiveSelect hooks + inside these bodies would be destroyed and re-mounted if the markup + left the DOM. The `hidden` class rather than the HTML attribute, + because a utility-layer `flex` would win over preflight's + `[hidden] { display: none }`. --%> +
      + {render_slot(@inner_block)} +
      +
      + """ + end + + # One message region for the whole tab, at its foot. It used to be two, keyed + # by a `scope` on the message โ€” which only made sense while the tab had a Save + # per section and those sections were far enough apart that a result had to + # land next to the control that produced it. Every control now acts where it + # stands, so there is one place a result belongs, in view from all of them. + attr :message, :any, default: nil + + defp panel_message(assigns) do + ~H""" +

      + {@message.text} +

      + """ + end + + defp message_class(:error), do: "text-sm text-red-400" + # Deliberately not green. Green is already spoken for by `status_class/1`'s + # `:delivering` pill, and a transient "Saved." in the same colour a few lines + # away reads as a second status rather than an acknowledgement. + defp message_class(:info), do: "text-sm text-sky-400" + + # Unifies excluded-systems and focus-corporation removal onto one visual + # treatment (Minors: chips) โ€” both used to render the same semantics two + # different ways (a bare `
      • ` versus a rounded chip) sitting one + # panel apart. `:rest` picks up `phx-click`/`phx-value-*`/`phx-target` + # unchanged (they're all `phx-`-prefixed, in the global attribute set by + # default), so each call site is just the label plus its own removal event. + attr :label, :string, required: true + attr :rest, :global + + defp chip(assigns) do + ~H""" +
      • + {@label} + +
      • + """ + end + + attr :role, :atom, required: true + attr :collisions, :list, required: true + + defp collision_warning(assigns) do + assigns = assign(assigns, :partners, collision_partners(assigns.collisions, assigns.role)) + + ~H""" +

        + {collision_text(@role, @partners)} +

        + """ + end + + attr :role, :atom, required: true + attr :title, :string, required: true + # Why an operator would want this destination, in one line. Shown whenever + # the row is not configured โ€” the state in which that question is actually + # being asked โ€” and while editing, where it explains what is being replaced. + # Suppressed on a configured, closed row, where the channel name on the same + # line already answers it. + attr :help, :string, default: nil + attr :webhook, :any, required: true + attr :form, :any, required: true + attr :replacing?, :boolean, required: true + attr :removable?, :boolean, required: true + # The kill row's delivery status is promoted to the card level (P1 + # hierarchy) โ€” the status line and failure line live just above this + # component, so repeating them here would be the exact "identical to the help + # text above it" problem being fixed. The other rows keep their own status. + attr :show_status?, :boolean, default: true + # What the status block says when nothing has ever been delivered. The + # default is the kill wording because two of the three rows are kill + # destinations; the route row overrides it, because "No kills delivered yet" + # under a channel that only ever carries route alerts reads as a fault. + attr :empty_status_text, :string, default: "No kills delivered yet." + attr :myself, :any, required: true + + # Resolved identity for `@webhook`, or nil. Passed in rather than looked up + # here so that a background refresh landing has an assign to invalidate. + attr :channel_info, :map, default: nil + + defp webhook_row(assigns) do + ~H""" +
        + <%!-- Closed โ€” configured or not. One line of truth: what this row is, + where it posts (or that it does not), whether that is working, and + the one control that opens everything else. The URL field, the + enabled toggle, Save, test and remove all live behind Edit/Add, + because none of them describe the current state and all of them + cost vertical space on every render of a tab that already does not + fit. + + An unconfigured row renders here too, rather than opening its form + on sight. All three destinations are always listed now, and three + credential fields on arrival is not an introduction to a feature โ€” + it is a form nobody asked for. + + A degraded or disabled destination still says so here rather than + auto-expanding: expanding would put a credential field on screen + unasked, and the operator needs to read the fault before deciding + to touch the URL. --%> +
        +

        {@title}

        + + + {channel_label(@channel_info)} + + Not set + + + โ— {status_label(status_state(@webhook))} + + + <.button + type="button" + variant={:ghost} + phx-click="replace-url" + phx-value-role={@role} + phx-target={@myself} + > + {if @webhook, do: "Edit", else: "Add"} + +
        + +

        + Last error: {@webhook.last_error} + 0}> + ({@webhook.consecutive_failures} consecutive failures) + +

        + +

        {@help}

        + + <.form + :let={wf} + :if={@replacing?} + for={@form} + id={"webhook-form-#{@role}"} + phx-submit="save-webhook" + phx-value-role={@role} + phx-target={@myself} + class="flex flex-col gap-2 pt-1" + > + <.input + field={wf[:webhook_url]} + type="password" + label="Discord webhook URL" + placeholder={ + if @webhook, + do: "Leave blank to keep the current URL", + else: "https://discord.com/api/webhooks/..." + } + autocomplete="off" + /> + + <.input :if={@webhook} field={wf[:enabled]} type="checkbox" label="Enabled" /> + +
        + + Last delivered: {Calendar.strftime(@webhook.last_delivery_at, "%Y-%m-%d %H:%M UTC")} + + {@empty_status_text} +
        + +
        + <.button type="submit" variant={:primary}> + {if @webhook, do: "Save", else: "Add"} + + <.button + type="button" + variant={:ghost} + phx-click="cancel-replace" + phx-value-role={@role} + phx-target={@myself} + > + Cancel + + <.button + :if={@webhook} + type="button" + phx-click="send-test" + phx-value-webhook_id={@webhook.id} + phx-target={@myself} + > + Send test message + + <.button + :if={@webhook && @removable?} + type="button" + variant={:danger} + phx-click="remove-webhook" + phx-value-role={@role} + phx-target={@myself} + data-confirm="Remove this Discord destination?" + > + Remove + +
        + +
        + """ + end + + # Mention targets for the route alert channel. Rendered as chips plus two + # pickers rather than a CSV field: a Discord mention of an id that does not + # exist in this guild renders as inert text with no error, so the only + # reliable defence is to source ids from the guild itself. + attr :users, :list, required: true + attr :roles, :list, required: true + attr :labels, :map, required: true + attr :guild_roles, :any, required: true + attr :picker_available?, :boolean, required: true + attr :unavailable_reason, :string, default: nil + attr :user_select_id, :string, required: true + attr :role_select_id, :string, required: true + attr :user_options, :list, required: true + attr :role_options, :list, required: true + attr :search_error, :string, default: nil + attr :error, :string, default: nil + attr :myself, :any, required: true + + defp mentions_section(assigns) do + ~H""" +
        + <%!-- No heading or border of its own: this now renders inside the + "Mentions" disclosure, which supplies both. --%> +

        + Who to ping when a route opens. Leave both empty to post with no ping. +

        + + <.mention_group + kind={:role} + title="Roles" + chips={@roles} + labels={@labels} + empty="No roles pinged." + myself={@myself} + /> + <.mention_group + kind={:user} + title="Users" + chips={@users} + labels={@labels} + empty="No users pinged." + myself={@myself} + /> + +
        + <.form + :let={f} + for={to_form(%{}, as: :mention_role)} + id="mention-role-form" + phx-change="add-mention-role" + phx-target={@myself} + > + <.live_select + field={f[:mention_role]} + id={@role_select_id} + phx-target={@myself} + label="Add a role" + mode={:single} + compact={true} + debounce={150} + update_min_len={0} + options={@role_options} + dropdown_extra_class="!h-24" + placeholder={ + if @guild_roles == :loading, do: "Loading rolesโ€ฆ", else: "Search roles in this server" + } + /> + + + <.form + :let={f} + for={to_form(%{}, as: :mention_user)} + id="mention-user-form" + phx-change="add-mention-user" + phx-target={@myself} + > + <.live_select + field={f[:mention_user]} + id={@user_select_id} + phx-target={@myself} + label="Add a user" + mode={:single} + compact={true} + debounce={250} + update_min_len={2} + options={@user_options} + dropdown_extra_class="!h-24" + placeholder="Search members in this server" + /> + + +

        {@search_error}

        +
        + + <%!-- D7's fallback. Reached whenever the guild cannot be read at all โ€” + no bot on this instance, no resolved guild yet, or a bot that is + not in this operator's server. Typing an id is still the whole + feature, so the section stays usable rather than disappearing. --%> +
        +

        {@unavailable_reason}

        + + <.mention_manual_form kind="role" label="Add a role by id" myself={@myself} /> + <.mention_manual_form kind="user" label="Add a user by id" myself={@myself} /> +
        + +

        {@error}

        +
        + """ + end + + attr :kind, :atom, required: true + attr :title, :string, required: true + attr :chips, :list, required: true + attr :labels, :map, required: true + attr :empty, :string, required: true + attr :myself, :any, required: true + + defp mention_group(assigns) do + ~H""" +
        + {@title} +

        {@empty}

        +
        + + <%!-- The label is decoration over the id, which is the state. An id + with no learned name still renders โ€” as the id โ€” rather than + vanishing from a list the operator saved. --%> + + @{mention_label(@labels, @kind, id)} + + + +
        +
        + """ + end + + attr :kind, :string, required: true + attr :label, :string, required: true + attr :myself, :any, required: true + + defp mention_manual_form(assigns) do + ~H""" + <.form + :let={f} + for={to_form(%{}, as: :mention_id)} + id={"mention-manual-#{@kind}"} + phx-submit="add-mention-id" + phx-value-kind={@kind} + phx-target={@myself} + class="flex items-end gap-2" + > + <.input field={f[:value]} type="text" label={@label} placeholder="17-20 digit id" /> + <.button type="submit">Add + + """ + end + + attr :notification, :any, required: true + attr :webhooks, :any, required: true + attr :route_toggle, :boolean, required: true + + # D4's reachable warning: both halves of "disable, don't hide". The + # alerts-on-but-no-channel case is the pre-existing guard; the + # alerts-off-but-configured case is the P0 this rework was written for. + # + # Neither carries an action button any more. "Enable route alerts" was a + # second, differently-shaped control for a checkbox that is now three lines + # away and always visible โ€” two places to do one thing, one of which + # committed immediately while the other waited for Save. + defp route_alert_banner(assigns) do + ~H""" +

        + Route alerts are on, but no route alert channel is ready โ€” nothing is being sent. +

        + +

        + Route alerts are switched off, but this channel is configured โ€” nothing is being sent to it. +

        + """ + end + + @impl true + def render(assigns) do + ~H""" +
        + <%!-- One card, two subsections, no global controls. There is no longer a + separate "no record yet" screen asking for a kill webhook before + anything else appears: kills and route alerts are independent + features that happen to share a policy row, and gating the second + on the first made an implementation detail into a setup step. The + row is created lazily by whichever control is touched first (see + `upsert/2`). --%> +
        +
        +

        Kill notifications

        + + <%!-- The two switches sit ABOVE both destination rows, because they + govern both. Underneath the character row โ€” where they used to + be โ€” they read as that row's settings, which is exactly + backwards for "Send kill notifications": it is the map-level + master switch. + + They apply on change, with no Save of their own. A checkbox + whose effect is deferred to a button somewhere below the fold + is a checkbox that lies about its own state, and these two are + the controls an operator reaches for in a hurry. The route + fields cannot do this โ€” they are validated against each other + โ€” which is what the one Save at the bottom is for. --%> + <.form + :let={f} + for={@kills_form} + id="kill-toggles-form" + phx-change="toggle-setting" + phx-target={@myself} + class="flex flex-col gap-2" + > + <.input field={f[:enabled]} type="checkbox" label="Send kill notifications" /> + <.input field={f[:wh_only]} type="checkbox" label="Only wormhole kills" /> + + +

        + {status_line(@webhooks[:system], @channel_hints[:system])} +

        +

        + Last error: {@webhooks[:system].last_error} + 0}> + ({@webhooks[:system].consecutive_failures} consecutive failures) + +

        + +
        + <.webhook_row + role={:system} + title="Kill channel" + help="Where kills on this map are posted." + webhook={@webhooks[:system]} + channel_info={@channel_hints[:system]} + form={@webhook_forms[:system]} + replacing?={@replacing_url?[:system]} + removable?={true} + show_status?={false} + myself={@myself} + /> + <.collision_warning role={:system} collisions={@collisions} /> + + <%!-- Always listed, rather than hidden behind "+ Add a separate + channel". That link asked the operator to commit before it + would say what they were committing to, and the row it + revealed was titled differently from the link that revealed + it. A named row with "Not set" beside it answers the + question โ€” is there one, and what would it do โ€” without + costing anything but a line. --%> + <.webhook_row + role={:character} + title="Character kill channel" + help="Optional second channel for kills involving characters tracked on this map, wherever they happen. Leave it unset and those kills go to the kill channel with the rest." + webhook={@webhooks[:character]} + channel_info={@channel_hints[:character]} + form={@webhook_forms[:character]} + replacing?={@replacing_url?[:character]} + removable?={true} + myself={@myself} + /> + <.collision_warning role={:character} collisions={@collisions} /> +
        + + <.disclosure + id="filters-disclosure" + title="Kill filters" + open?={MapSet.member?(@open_sections, "filters-disclosure")} + myself={@myself} + badge={ + filters_badge(@excluded_systems, @focus_corps, @system_search_error, @corp_search_error) + } + > + <%!-- The one sentence kept from the tab's old intro paragraph. It + sits here rather than at the top because it disambiguates these + filters specifically, and at the top it was answering a question + nobody had yet asked about a section that was collapsed. --%> +

        + Separate from the Kills widget's own filters, which are per-user and only + change what you see on the map. Neither applies to route alerts. +

        + +
        +

        Excluded systems

        + +
          +
        • + <.chip + label={label} + phx-click="remove-excluded" + phx-value-system_id={system_id} + phx-target={@myself} + /> +
        • +
        + + <%!-- Height matching, without predicting either box: `compact` makes the + field wrapper exactly as tall as its input, `!py-0` drops the + button's vertical padding so its intrinsic height (one line of + text) is always shorter than the input's, and the grid row is + therefore sized by the field. `items-stretch` then gives the + button that exact height. Nothing here depends on the button and + the input agreeing on font size, padding or line-height, which + they do not. --%> + <.form + :let={ef} + for={@excluded_form} + id="excluded-system-form" + phx-submit="add-excluded" + phx-target={@myself} + class="grid items-stretch gap-2" + style="grid-template-columns: 1fr auto" + > + <.live_select + field={ef[:excluded_system]} + id={@excluded_select_id} + phx-target={@myself} + label="Exclude a system" + dropdown_extra_class="!h-24" + compact={true} + debounce={250} + update_min_len={@min_search_length} + mode={:single} + options={@system_options} + placeholder="Search a system by name" + /> + <.button type="submit" class="!py-0 inline-flex items-center justify-center"> + Add + + + +

        {@system_search_error}

        +
        + +
        +

        Corporation filter

        + <%!-- Two sentences, by D10. The full routing rules โ€” that a + corporation match replaces the tracked-character test rather + than widening it, and bypasses the excluded-system and + wormhole-only filters โ€” are in docs/ZOO-FORK.md under + "Kill filter semantics". A four-sentence paragraph next to an + input is not read; it is scrolled past. --%> +

        + When set, the character kill channel follows these corporations instead of + this map's tracked characters. Leave it empty to use the tracked characters. +

        + +
          +
        • + <.chip + label={label} + phx-click="remove-focus-corp" + phx-value-corp_id={corp_id} + phx-target={@myself} + /> +
        • +
        + +

        + Add a character to this account to search corporations. +

        + + <.form + :let={cf} + :if={@current_user.characters not in [nil, []]} + for={@focus_corp_form} + id="focus-corp-form" + phx-submit="add-focus-corp" + phx-target={@myself} + class="grid items-stretch gap-2" + style="grid-template-columns: 1fr auto" + > + <.live_select + field={cf[:focus_corp]} + id={@focus_corp_select_id} + phx-target={@myself} + label="Add a corporation" + dropdown_extra_class="!h-24" + compact={true} + debounce={250} + update_min_len={@corp_min_search_length} + mode={:single} + options={@corp_options} + placeholder="Search a corporation by name" + /> + <.button type="submit" class="!py-0 inline-flex items-center justify-center"> + Add + + + +

        {@corp_search_error}

        +
        + +
        + +
        +

        Route alerts

        + + <.route_alert_banner + notification={@notification} + webhooks={@webhooks} + route_toggle={@route_toggle} + /> + + <%!-- The three route fields are one form because they are validated + against each other: enabling alerts without a home system is + rejected by the resource, so the toggle cannot commit on change + the way the kill switches do. This is the form the Save button + at the bottom of the pane submits, via `form=` โ€” it is a submit + button, so no hidden-companion or event-bubbling caveat + applies, and a nested would have been dropped by the + browser outright. --%> + <.form + :let={rf} + for={@settings_form} + id="notification-settings-form" + phx-submit="save-settings" + phx-change="settings-change" + phx-target={@myself} + class="flex flex-col gap-2" + > + <%!-- A checkbox, not a button. "Enable route alerts" was a button + because the fields below it needed a home system before they + could be saved โ€” but that is what the disabled fieldset and + the validation are for, and the operator was left with two + differently-shaped controls for one boolean. --%> + <.input + field={rf[:route_alerts_enabled]} + type="checkbox" + label="Send route alerts" + phx-change="settings-change" + phx-target={@myself} + /> + + <%!-- Disabled rather than hidden (upstream checklist ยง5), and disabled + through a
        rather than per-input for two reasons. It + natively disables every descendant control including + LiveSelect's own text and hidden inputs โ€” `live_select/1` + declares no `disabled` attr, and adding one belongs to + core_components' owner, not this file. And it is a real + disable: keyboard and AT see it, unlike a pointer-events or + opacity trick. + + Nothing here submits while disabled, which is safe only + because `notification_attrs/1` treats an absent key as "leave + this field alone" โ€” otherwise pressing Save with alerts off + would clear a saved home system. --%> +
        +
        + <%!-- The visible text; the associated label on the combobox + itself is screen-reader-only (see `live_select/1`), so this + is hidden from AT to avoid announcing the name twice. --%> + + <.live_select + field={rf[:home_system_id]} + id={@home_system_select_id} + phx-target={@myself} + label="Home system" + dropdown_extra_class="!h-24" + compact={true} + debounce={250} + update_min_len={@min_search_length} + mode={:single} + options={@home_system_options} + placeholder="Search a system by name, or enter its solar system ID" + /> +

        {@home_system_error}

        +

        + {@home_system_search_error} +

        +
        + + <.input + field={rf[:route_max_jumps]} + type="number" + min={@min_route_max_jumps} + max={@max_route_max_jumps} + label="Max jumps to Jita (inclusive)" + /> +
        + +

        + Posts when a highsec-only route this length or shorter opens from the home + system to Jita. Wormhole hops on the way don't count against "highsec" โ€” + only k-space systems on the path do. +

        + + <%!-- Inside the form it commits, not in a pane-level action bar. The + three fields above are the only ones on this tab that cannot + apply on change: `route_max_jumps` is typed, so a per-keystroke + save would submit "" mid-edit against an `allow_nil? false` + attribute, and the toggle and home system are interdependent โ€” + `validate_home_system_required/2` rejects "enabled with no home + system", so committing the tick on its own is exactly how an + operator gets told off for a field they have not reached yet. + Everything else on the tab acts where it stands. --%> +
        + <.button type="submit" variant={:primary} disabled={not @dirty}> + Save route alerts + +
        + + + <.webhook_row + role={:route} + title="Route alert channel" + help="Where route alerts are posted. Alerts name every system on the route, unredacted โ€” treat this channel as trusted." + webhook={@webhooks[:route]} + channel_info={@channel_hints[:route]} + form={@webhook_forms[:route]} + replacing?={@replacing_url?[:route]} + removable?={true} + empty_status_text="No route alerts delivered yet." + myself={@myself} + /> + <.collision_warning role={:route} collisions={@collisions} /> + + <.disclosure + :if={@webhooks[:route]} + id="mentions-disclosure" + title="Mentions" + open?={MapSet.member?(@open_sections, "mentions-disclosure")} + myself={@myself} + badge={mentions_badge(@mention_users, @mention_roles)} + > + <.mentions_section + users={@mention_users} + roles={@mention_roles} + labels={@mention_labels} + guild_roles={@guild_roles} + picker_available?={mention_picker_available?(assigns)} + unavailable_reason={mention_unavailable_reason(assigns)} + user_select_id={@mention_user_select_id} + role_select_id={@mention_role_select_id} + user_options={@mention_user_options} + role_options={@mention_role_options} + search_error={@mention_search_error} + error={@mention_error} + myself={@myself} + /> + +
        +
        + + <%!-- The message region is shared: every control on this tab reports here, + not just the one Save. It sits at the foot because that is the one + place in view from any of them. --%> +
        + <.panel_message message={@message} /> +
        +
        + """ + end + + defp mentions_badge([], []), do: "None" + + defp mentions_badge(users, roles) do + [count_label(length(roles), "role"), count_label(length(users), "user")] + |> Enum.reject(&is_nil/1) + |> Enum.join(", ") + end + + defp count_label(0, _noun), do: nil + defp count_label(1, noun), do: "1 #{noun}" + defp count_label(n, noun), do: "#{n} #{noun}s" +end diff --git a/lib/wanderer_app_web/live/maps/maps_live.ex b/lib/wanderer_app_web/live/maps/maps_live.ex index aed8c2528..470290f8e 100644 --- a/lib/wanderer_app_web/live/maps/maps_live.ex +++ b/lib/wanderer_app_web/live/maps/maps_live.ex @@ -7,6 +7,22 @@ defmodule WandererAppWeb.MapsLive do @pubsub_client Application.compile_env(:wanderer_app, :pubsub_client) + # Settings tabs that are always available to anyone who can open the dialog. + # The dialog itself is already gated on the `delete_map` permission in + # `apply_action(:settings, ...)`, so this list is not a permission boundary โ€” + # it stops a crafted `change_settings_tab` event from selecting a tab that was + # never rendered, which would otherwise defeat the feature-flag `:if` guards + # on the tab list in maps_live.html.heex. + @always_available_settings_tabs ~w(general import notifications) + + # Tabs whose availability tracks a deployment feature flag. Each entry mirrors + # the `:if` guard on the corresponding
      • in maps_live.html.heex; keep the + # two in sync. + @subscription_settings_tabs ~w(balance subscription bot) + @public_api_settings_tab "public_api" + + @default_settings_tab "general" + @impl true def mount( _params, @@ -175,7 +191,7 @@ defmodule WandererAppWeb.MapsLive do importing: false, show_settings?: true, is_topping_up?: false, - active_settings_tab: "general", + active_settings_tab: @default_settings_tab, is_adding_subscription?: false, selected_subscription: nil, options_form: options_form_data |> to_form(), @@ -209,6 +225,19 @@ defmodule WandererAppWeb.MapsLive do end end + defp settings_tab_available?(tab, _assigns) + when tab in @always_available_settings_tabs, + do: true + + defp settings_tab_available?(tab, %{map_subscriptions_enabled?: subscriptions_enabled?}) + when tab in @subscription_settings_tabs, + do: subscriptions_enabled? + + defp settings_tab_available?(@public_api_settings_tab, _assigns), + do: not WandererApp.Env.public_api_disabled?() + + defp settings_tab_available?(_tab, _assigns), do: false + defp allow_map_creation(), do: not WandererApp.Env.restrict_maps_creation?() || WandererApp.Cache.take("create_map_once") @@ -396,8 +425,15 @@ defmodule WandererAppWeb.MapsLive do end @impl true - def handle_event("change_settings_tab", %{"tab" => tab}, socket), - do: {:noreply, socket |> assign(active_settings_tab: tab)} + def handle_event("change_settings_tab", %{"tab" => tab}, socket) do + if settings_tab_available?(tab, socket.assigns) do + {:noreply, socket |> assign(active_settings_tab: tab)} + else + # Unknown or feature-disabled tab: keep the current selection rather than + # rendering a panel whose
      • was never shown. + {:noreply, socket} + end + end def handle_event("open_acl", %{"data" => id}, socket) do {:noreply, @@ -611,6 +647,47 @@ defmodule WandererAppWeb.MapsLive do {:noreply, socket |> put_flash(type, message)} end + # A background Discord channel-identity refresh landed. The notifications tab + # rendered a masked hint from a cold cache; this is what replaces it with the + # real name without the operator having to close and reopen the tab. + # + # Three elements, not two: the clause below would otherwise swallow it and + # hand a non-reference to `Process.demonitor/2`, which raises. See + # `ChannelInfo.describe/2`. + @impl true + def handle_info({:discord_channel_info, _notification_id, _source}, socket) do + # Only push into the component while it is actually mounted. The refresh is + # scheduled from its render path but resolves over the network, so the + # operator can easily have switched tabs or closed the modal by the time it + # lands โ€” and `send_update/3` against a component that is no longer rendered + # is a logged error for a result nobody is waiting on. + if socket.assigns[:live_action] == :settings and + socket.assigns[:active_settings_tab] == "notifications" do + send_update(WandererAppWeb.MapNotificationsComponent, + id: "map-notifications", + channel_info_refreshed: true + ) + end + + {:noreply, socket} + end + + # The guild's role list for the mention picker, read off the render path for + # the same reason as the identity refresh above: `HttpClient` can spend + # several seconds before it fails, and the settings dialog must not wait on + # it. Three elements for the same `Process.demonitor/2` reason. + def handle_info({:discord_guild_roles, guild_id, result}, socket) do + if socket.assigns[:live_action] == :settings and + socket.assigns[:active_settings_tab] == "notifications" do + send_update(WandererAppWeb.MapNotificationsComponent, + id: "map-notifications", + guild_roles: {guild_id, result} + ) + end + + {:noreply, socket} + end + @impl true def handle_info( {ref, result}, @@ -806,6 +883,49 @@ defmodule WandererAppWeb.MapsLive do |> Map.put(:acls, acls |> Enum.map(&map_acl/1)) end + # Single source of truth for the Map Settings tab strip. The markup was + # originally copied out of a rendered PrimeReact TabView, which left seven + # near-identical `
      • ` blocks carrying hand-written `aria-selected` and + # `aria-controls` values that never matched the real state or the real panel. + # Rendering from this list keeps the ARIA computed instead of transcribed. + defp settings_tabs(map_subscriptions_enabled?) do + [ + %{id: "general", label: "General", icon: "hero-wrench-screwdriver-solid", show?: true}, + %{ + id: "balance", + label: "Balance", + icon: "hero-banknotes-solid", + show?: map_subscriptions_enabled? + }, + %{ + id: "subscription", + label: "Subscription", + icon: "hero-check-badge-solid", + show?: map_subscriptions_enabled? + }, + %{ + id: "import", + label: "Import/Export", + icon: "hero-document-arrow-down-solid", + show?: true + }, + %{ + id: "public_api", + label: "Public Api", + icon: "hero-globe-alt-solid", + show?: not WandererApp.Env.public_api_disabled?() + }, + %{ + id: "bot", + label: "Bots", + icon: "hero-puzzle-piece-solid", + show?: map_subscriptions_enabled? + }, + %{id: "notifications", label: "Notifications", icon: "hero-bell-alert-solid", show?: true} + ] + |> Enum.filter(& &1.show?) + end + defp available_scopes do [ %{value: "wormholes", label: "Wormholes", description: "J-space systems"}, diff --git a/lib/wanderer_app_web/live/maps/maps_live.html.heex b/lib/wanderer_app_web/live/maps/maps_live.html.heex index e8c138a9c..2bf85fe88 100644 --- a/lib/wanderer_app_web/live/maps/maps_live.html.heex +++ b/lib/wanderer_app_web/live/maps/maps_live.html.heex @@ -244,10 +244,14 @@ +<%!-- Width: `min()` rather than a `w-full md:` pair. `w-full` made the dialog + fill the mask on every viewport, and CSS resolves min-width AFTER + max-width, so a bare `!min-w-[700px]` would overflow a phone instead of + shrinking. This keeps the 700px working width and collapses below it. --%> <.modal :if={@live_action in [:settings] && not is_nil(assigns[:map])} title="Map Settings" - class="!min-w-[700px]" + class="!min-w-[min(700px,100%)]" id="map-settings-modal" show on_cancel={JS.patch(~p"/maps")} @@ -260,175 +264,48 @@
      @@ -634,6 +511,14 @@ current_user={@current_user} readonly={false} /> + + <.live_component + :if={@active_settings_tab == "notifications"} + module={WandererAppWeb.MapNotificationsComponent} + id="map-notifications" + map_id={@map.id} + current_user={@current_user} + />
      diff --git a/lib/wanderer_app_web/presence_grace_period_manager.ex b/lib/wanderer_app_web/presence_grace_period_manager.ex index 4a471a391..f0a461bf7 100644 --- a/lib/wanderer_app_web/presence_grace_period_manager.ex +++ b/lib/wanderer_app_web/presence_grace_period_manager.ex @@ -218,8 +218,9 @@ defmodule WandererAppWeb.PresenceGracePeriodManager do _timer_ref -> # Grace period expired and is still valid - perform atomic removal Logger.info(fn -> - "[PresenceGracePeriod] Grace period expired for character #{character_id} on map #{map_id} - " <> - "removing from tracking after #{div(@grace_period_ms, 60_000)} minutes of inactivity" + "[PresenceGracePeriod] Removing character #{character_id} from map #{map_id} " <> + "after #{div(@grace_period_ms, 60_000)}min grace period, " <> + "reason=grace_period_expired" end) # Remove from pending removals state diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex index 4eab48b9b..80afaf870 100644 --- a/lib/wanderer_app_web/router.ex +++ b/lib/wanderer_app_web/router.ex @@ -174,6 +174,13 @@ defmodule WandererAppWeb.Router do plug WandererAppWeb.Plugs.CheckApiDisabled end + # Deliberately minimal. Fly kills a machine that fails its health check and + # there is exactly one machine, so nothing that can be switched off by + # configuration may appear here โ€” no CheckApiDisabled, no auth, no rate limit. + pipeline :health do + plug :accepts, ["json"] + end + # Versioned API pipeline with enhanced security and validation pipeline :api_versioned do plug WandererAppWeb.Plugs.ContentNegotiation, accepts: ["json"] @@ -338,6 +345,7 @@ defmodule WandererAppWeb.Router do get "/:id", MapAccessListAPIController, :show put "/:id", MapAccessListAPIController, :update post "/:acl_id/members", AccessListMemberAPIController, :create + get "/:acl_id/members/:member_id", AccessListMemberAPIController, :show put "/:acl_id/members/:member_id", AccessListMemberAPIController, :update_role delete "/:acl_id/members/:member_id", AccessListMemberAPIController, :delete end @@ -373,8 +381,14 @@ defmodule WandererAppWeb.Router do # Health Check Endpoints # Used for monitoring, load balancer health checks, and deployment validation # - scope "/api", WandererAppWeb do - pipe_through [:api] + # This scope's POSITION IN THE FILE IS LOAD-BEARING. It must stay above the + # `live "/:slug", MapLive, :index` wildcard further down. Phoenix matches + # routes in definition order, so below that line `/health` is swallowed by the + # wildcard and answers 302 to /welcome instead of 200. + scope "/", WandererAppWeb do + pipe_through [:health] + + get "/health", HealthController, :index end # scope "/api/licenses", WandererAppWeb do @@ -421,8 +435,6 @@ defmodule WandererAppWeb.Router do get "/", BlogController, :license end - - scope "/swaggerui" do pipe_through [:browser, :api_spec] diff --git a/mix.exs b/mix.exs index 7960bacc7..10c4c5a43 100644 --- a/mix.exs +++ b/mix.exs @@ -32,7 +32,11 @@ defmodule WandererApp.MixProject do include_executables_for: [:unix], steps: [:assemble, :tar], applications: [ - wanderer_app: :permanent + wanderer_app: :permanent, + # runtime: false keeps Nostrum out of the release unless listed; :load + # ships the code without auto-starting it โ€” VoiceGateway starts it only + # when voice mentions are configured. + nostrum: :load ], version: "1.0.0" ] @@ -77,7 +81,10 @@ defmodule WandererApp.MixProject do {:phoenix_live_reload, "~> 1.5.3", only: :dev}, {:phoenix_live_view, "~> 1.0.0-rc.7", override: true}, {:phoenix_pubsub, "~> 2.1"}, - {:phoenix_gen_socket_client, "~> 4.0"}, + # Pinned: WandererApp.Kills.Transport.WebSocketClient depends on this + # library's private handler-state shape. Re-verify that shim before + # bumping. + {:phoenix_gen_socket_client, "== 4.0.0"}, {:websocket_client, "~> 1.5"}, {:floki, ">= 0.30.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, @@ -88,7 +95,9 @@ defmodule WandererApp.MixProject do {:finch, "~> 0.13"}, {:telemetry_metrics, "~> 1.0", override: true}, {:telemetry_poller, "~> 1.0"}, - {:gettext, "~> 0.20"}, + # 0.26 is the floor: WandererAppWeb.Gettext uses `Gettext.Backend`, which + # does not exist before it. + {:gettext, "~> 0.26"}, {:jason, "~> 1.4"}, {:dns_cluster, "~> 0.1.1"}, {:plug_cowboy, "~> 2.5"}, @@ -117,6 +126,7 @@ defmodule WandererApp.MixProject do {:prom_ex, "~> 1.9"}, {:fresh, "~> 0.4.4"}, {:nimble_publisher, "~> 1.0"}, + {:nostrum, "~> 0.10", runtime: false}, {:makeup_elixir, ">= 0.0.0"}, {:makeup_erlang, ">= 0.0.0"}, {:better_number, "~> 1.0.0"}, diff --git a/mix.lock b/mix.lock index 87e4410e8..90de7f061 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,7 @@ "better_number": {:hex, :better_number, "1.0.1", "5832757e2575feda6f6e67b3ff18f1510a42efec4f5673221f89cff8132add7b", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "782efdaf7bb4a7109265878fa30497a335bf7cd5954ce37ee539a3ce7cf09ceb"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, + "castle": {:hex, :castle, "0.3.1", "e5d4f20696d878052a23c13158e1d372b24d9b30a6ea6f52fa6063c21c5ad67e", [:mix], [{:forecastle, "~> 0.1.3", [hex: :forecastle, repo: "hexpm", optional: false]}], "hexpm", "3ee9ca04b069280ab4197fe753562958729c83b3aa08125255116a989e133835"}, "castore": {:hex, :castore, "1.0.16", "8a4f9a7c8b81cda88231a08fe69e3254f16833053b23fa63274b05cbc61d2a1e", [:mix], [], "hexpm", "33689203a0eaaf02fcd0e86eadfbcf1bd636100455350592e7e2628564022aaf"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "cloak": {:hex, :cloak, "1.1.4", "aba387b22ea4d80d92d38ab1890cc528b06e0e7ef2a4581d71c3fdad59e997e7", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "92b20527b9aba3d939fab0dd32ce592ff86361547cfdc87d74edce6f980eb3d7"}, @@ -17,7 +18,7 @@ "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.14.0", "623791c56c1cc9df54a71a9c55147a401549917f00a2e48a6ae12b812c586ced", [:make, :rebar3], [], "hexpm", "0af652d1550c8411c3b58eed7a035a7fb088c0b86aff6bc504b0bc3b7f791aa2"}, + "cowlib": {:hex, :cowlib, "2.19.0", "c9d11c9d035472e27a740c9f327786c61ed209269b4be0260d59d3ec07b8949f", [:make, :rebar3], [], "hexpm", "6dc66e3135b229193ea4dcb14294e79520c923d391315c9c962ef0b4bea72356"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "crontab": {:hex, :crontab, "1.1.13", "3bad04f050b9f7f1c237809e42223999c150656a6b2afbbfef597d56df2144c5", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "d67441bec989640e3afb94e123f45a2bc42d76e02988c9613885dc3d01cf7085"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, @@ -48,12 +49,14 @@ "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, + "forecastle": {:hex, :forecastle, "0.1.3", "b07d217ef10799e6d6cc7e47407858e77b1a8cb248f15185534de3403de3aa42", [:mix], [], "hexpm", "07e1ffa79c56f3e0ead59f17c0163a747dafc210ca8f244a7e65a4bfa98dc96d"}, "fresh": {:hex, :fresh, "0.4.4", "9d67a1d97112e70f4dfabd63b40e4b182ef64dfa84a2d9ee175eb4e34591e9f7", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mint, "~> 1.5", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}], "hexpm", "ba21d3fa0aa77bf18ca397e4c851de7432bb3f9c170a1645a16e09e4bba54315"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.6.1", "cc7799a68c26cf814d6d1a5121415b4f5bf813de200908f930b27a2f1fe9dad5", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "ce62d07e41fe993ec22c35d5edb11cf333a21ddaead6f5d9868fcb607d42039e"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, + "gun": {:hex, :gun, "2.5.0", "7be4c8d34a177f4213d48bb5fbffb03462ce871b4ec30b5aa3b08d2fd4fa424b", [:make, :rebar3], [{:cowlib, ">= 2.19.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "3839576181456f5553fc1be006fd95681576b916cc9ad68d422a287ed4a770dd"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "heroicons": {:hex, :heroicons, "0.5.5", "c2bcb05a90f010df246a5a2a2b54cac15483b5de137b2ef0bead77fcdf06e21a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "2f4bf929440fecd5191ba9f40e5009b0f75dc993d765c0e4d068fcb7026d6da1"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, @@ -88,6 +91,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_publisher": {:hex, :nimble_publisher, "1.1.0", "49dee0f30536140268996660a5927d0282946949c35c88ccc6da11a19231b4b6", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "80fb42d8d1e34f41ff29fc2a1ae6ab86ea7b764b3c2d38e5268a43cf33825782"}, + "nostrum": {:hex, :nostrum, "0.10.4", "a316d08b19104f34c5fd5aa56674907350899a5f0c2483afdf5586296bd0ce07", [:mix], [{:castle, "~> 0.3.0", [hex: :castle, repo: "hexpm", optional: false]}, {:certifi, "~> 2.13", [hex: :certifi, repo: "hexpm", optional: false]}, {:ezstd, "~> 1.1", [hex: :ezstd, repo: "hexpm", optional: true]}, {:gun, "~> 2.0", [hex: :gun, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "fcc2642bf5b09792865ec2c26c1a11c6aa5432bc623a65dd81141e1eab9f1b99"}, "oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, "open_api_spex": {:hex, :open_api_spex, "3.21.5", "ff0c7fe5ceff9a56b9b0bb5a6dcfb7bc96e8afc563a3bef6ae91927de4d38b8e", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "bd83c8f462222236fa85044098ba3bf57f7b7d7fd5286e6bc0060c7916f7c0d8"}, diff --git a/priv/posts/2026/08-02-discord-kill-notifications.md b/priv/posts/2026/08-02-discord-kill-notifications.md new file mode 100644 index 000000000..bb87aa478 --- /dev/null +++ b/priv/posts/2026/08-02-discord-kill-notifications.md @@ -0,0 +1,201 @@ +%{ +title: "New Feature: Discord Kill Notifications", +author: "Wanderer Team", +cover_image_uri: "/images/news/08-02-discord-kill-notifications/cover.png", +tags: ~w(discord notifications kills map settings guide), +description: "Post kills from your map straight into a Discord channel. Set a webhook once, filter to wormhole space, exclude the systems you don't care about." +} + +--- + +# Discord Kill Notifications + +Your chain is already telling you where the fights are โ€” the Kills widget shows +every killmail in the systems on your map. The catch is that somebody has to be +looking at it. If the map is on a second monitor nobody is watching, a hostile +gang rolling into your home hole looks exactly like an empty screen. + +So we added a direct line out: **Discord kill notifications**. Point your map at +a Discord webhook and kills in your chain get posted into the channel your +corp is already sitting in. + +## Setting it up + +Open **Map settings โ†’ Notifications**. The tab lives inside the map's settings +page, so it is available to whoever can administer the map โ€” in practice the map +owner and anyone granted admin rights over it. + +1. **Create a webhook in Discord.** In your Discord server, open + *Server Settings โ†’ Integrations โ†’ Webhooks*, create one, pick the channel it + should post to, and copy the webhook URL. +2. **Paste the URL** into the *Discord webhook URL (system channel)* field on + the Notifications tab and hit **Save**. +3. **Optionally add a character channel.** The *Character channel (optional)* + section takes a second webhook. Kills involving your own pilots go there + instead, so you can keep them out of the chain-intel channel. By default + "your own pilots" means the map's tracked characters; the **Corporation + filter** below the field changes that. If no character channel is + configured, these kills simply stay in the system channel โ€” the split is + opt-in. +4. **Send a test message** to confirm the wiring before you rely on it. The + button is right there under the form. + +That is the whole setup. From that point on, kills detected in systems on the +map are formatted and pushed to the channel. + +## What a notification looks like + +Each kill arrives as a Discord embed: + +- **Title** โ€” who lost what ("Some Pilot lost a Loki"), linking straight to the + killmail on zKillboard. +- **System**, **Value**, **Final blow**, **Corp**, and **Alliance** fields. The + final-blow field shows the number of other attackers, so `Some Pilot (+11)` + tells you at a glance whether this was a solo gank or a fleet. +- A **ship render** thumbnail and the victim's corp ticker in the footer. + +Fields that we have no data for are simply left out rather than posted as +"Unknown", so the embed stays readable. + +When a burst of kills lands at once โ€” a fleet fight, a gate camp working through +a convoy โ€” the embeds are batched into as few messages as Discord allows, and a +very large batch is capped with a "โ€ฆand N more kills not shown." line rather +than flooding your channel with a hundred separate posts. + +## Filters + +Two controls, both on the Notifications tab: + +- **Only wormhole kills** (on by default). Restricts notifications to J-space, + including Thera, shattered systems, and the drifter holes. Turn it off if you + want kills from every system on the map, k-space included. +- **Excluded systems.** Search a system by name and add it to the list. Kills + there are skipped. This is the one to use for your home system if you would + rather not get a ping every time somebody shoots a structure, or for a + highway system that generates constant noise. + +There is also an **Enabled** checkbox, so you can mute the feed without +throwing away the webhook and its filter list. + +**Both filters have a deliberate carve-out:** they do not apply to a kill that +involves one of your own pilots. A kill involving your people is interesting +wherever it happened, so it is still delivered even from an excluded system, and +even from k-space with *Only wormhole kills* left on. Those kills go to the +character channel when one is configured, and to the system channel otherwise. +If you want a system genuinely silent, the **Enabled** checkbox is the control +that covers everything. + +**One thing worth being clear about:** these filters are *not* the same as the +Kills widget filters. The widget's filters are per-user and only change what +*you* see in the map UI. The Discord filters are per-map and server-side โ€” they +apply to everyone in the channel. The two look similar and are deliberately +kept separate. + +## Corporation filter + +Under the character channel there is a **Corporation filter**. It answers one +question: *who is the character channel for?* + +- **Leave it empty** and the answer is "the characters tracked on this map." + That is the default and it is what the feature did before this setting + existed. +- **Add one or more corporations** and the answer becomes "members of these + corporations" โ€” *instead of* the map's tracked characters, not in addition to + them. + +That replacement is the point. If your map tracks a mixed group of scouts and +alts but the channel is meant to be your corp's kill feed, setting the filter +takes the untracked-corp pilots' kills out of it. Those kills are not lost โ€” +they fall through to the normal system rules and land in the system channel if +they pass the filters there. + +The carve-out above follows whichever criterion is active: with a corporation +filter set, kills involving those corporations bypass the excluded-system and +wormhole-only filters, and kills involving map-tracked characters no longer do. + +Matching looks at the victim first, then the attackers, and at *corporation* +membership on both sides โ€” so a corp member on either end of a killmail counts, +whether or not that character is on the map. + +## Where a kill goes + +The full routing table, in order. "Ours" means the kill matched the active +criterion โ€” a map-tracked character, or a filtered corporation if the +Corporation filter is set. + +1. Not ours, and the system is on the **excluded systems** list โ†’ dropped. +2. Not ours, *Only wormhole kills* is on, and the system is k-space โ†’ dropped. +3. Ours โ†’ the **character channel**, falling back to the system channel if no + character webhook is configured. +4. Anything else โ†’ the **system channel**. + +There is a third possibility besides "ours" and "not ours": *undetermined*. A +killmail can arrive without attacker information, or the map's tracked-character +list can be briefly unreadable after a restart. In that case the map genuinely +does not know whether the kill is yours. + +Undetermined kills are treated as neither. They do **not** get dropped by rules +1 and 2 โ€” those filters are only earned by a positive "this kill is not ours", +and treating a missing answer as a "no" would mean a brief hiccup silently +swallowed every k-space kill with the default settings. And they do **not** go +to the character channel either, because that channel is supposed to mean "these +are ours". They go to the system channel, and the server logs a warning so the +cause is visible rather than inferred from a quiet channel. + +## About the webhook URL + +A Discord webhook URL is a credential: anyone holding it can post to your +channel. So we treat it like one. + +- It is **stored encrypted** in the database. +- After you save it, it is never displayed in full again โ€” the settings tab + shows a masked hint like `.../123456/AbCdโ€ขโ€ขโ€ขโ€ข`. +- To point the map at a different channel, click **Replace** and paste the new + URL. There is no way to read the old one back out of the UI. + +If a webhook is deleted on the Discord side, Discord answers with a 404 and that +destination is disabled automatically โ€” no point retrying a channel that no +longer exists. Each destination carries its own enabled flag and health state, +so disabling the character channel this way leaves the system channel posting +normally, and vice versa. Other transient errors (rate limits, brief outages) +are retried with backoff for a bounded number of attempts, and only a sustained +run of failures will disable a destination. Those retries all happen inside the +one delivery attempt โ€” once the attempts are exhausted, the message is dropped +rather than re-queued, which is what keeps delivery at most once (see *Known +limits* below). + +## Self-hosting notes + +Wanderer CE runs this behind the same switch as the rest of the outbound events +system: + +```bash +export WANDERER_WEBHOOKS_ENABLED="true" +``` + +With it off, the Notifications tab still renders but "Send test message" will +tell you notifications are disabled on this server. + +Delivery uses its own isolated connection pool, so a slow Discord cannot back up +the rest of the application. If you run a large instance with many maps sending +notifications, you can size that pool: + +```bash +export WANDERER_DISCORD_POOL_SIZE="10" # default +``` + +## Known limits + +Worth knowing before you wire it into an intel channel: + +- Notifications are **at most once**. If a delivery fails outright, that kill is + not re-sent later. We would rather drop the occasional kill than double-post + into a chat channel, and a dropped kill is still visible in the Kills widget + and on zKillboard. +- Deduplication is in memory, so a restart of the application can let a kill + that was already posted be posted once more. +- Two channels per map: one for system kills, one for character kills. Splitting + further than that โ€” a channel per region, per corp, per anything else โ€” is not + supported yet. + +Fly safe. o7 diff --git a/priv/repo/migrations/20250122214138_add_zoo_flags.exs b/priv/repo/migrations/20250122214138_add_zoo_flags.exs new file mode 100644 index 000000000..f9b33dfd2 --- /dev/null +++ b/priv/repo/migrations/20250122214138_add_zoo_flags.exs @@ -0,0 +1,17 @@ +defmodule WandererApp.Repo.Migrations.AddZooFlags do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + execute("ALTER TABLE map_system_v1 ADD COLUMN IF NOT EXISTS custom_flags text") + end + + def down do + execute("ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS custom_flags") + end +end diff --git a/priv/repo/migrations/20250204223853_add_system_owners.exs b/priv/repo/migrations/20250204223853_add_system_owners.exs new file mode 100644 index 000000000..7b1be35eb --- /dev/null +++ b/priv/repo/migrations/20250204223853_add_system_owners.exs @@ -0,0 +1,20 @@ +defmodule WandererApp.Repo.Migrations.AddZooCustomAgain do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations`. + Modified to be idempotent by using raw SQL. + """ + + use Ecto.Migration + + def up do + execute("ALTER TABLE map_system_v1 ADD COLUMN IF NOT EXISTS owner_id text") + execute("ALTER TABLE map_system_v1 ADD COLUMN IF NOT EXISTS owner_type text") + end + + def down do + execute("ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_type") + execute("ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_id") + end +end diff --git a/priv/repo/migrations/20250307165740_add_owner_ticker.exs b/priv/repo/migrations/20250307165740_add_owner_ticker.exs new file mode 100644 index 000000000..eaf9841be --- /dev/null +++ b/priv/repo/migrations/20250307165740_add_owner_ticker.exs @@ -0,0 +1,17 @@ +defmodule WandererApp.Repo.Migrations.AddOwnerTicker do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + execute("ALTER TABLE map_system_v1 ADD COLUMN IF NOT EXISTS owner_ticker text") + end + + def down do + execute("ALTER TABLE map_system_v1 DROP COLUMN IF EXISTS owner_ticker") + end +end diff --git a/priv/repo/migrations/20250625024813_add_fleet_readiness_ready_characters.exs b/priv/repo/migrations/20250625024813_add_fleet_readiness_ready_characters.exs new file mode 100644 index 000000000..b521a652c --- /dev/null +++ b/priv/repo/migrations/20250625024813_add_fleet_readiness_ready_characters.exs @@ -0,0 +1,29 @@ +defmodule WandererApp.Repo.Migrations.AddFleetReadinessReadyCharacters do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'map_user_settings_v1' + AND column_name = 'ready_characters' + ) THEN + ALTER TABLE map_user_settings_v1 + ADD COLUMN ready_characters text[] DEFAULT '{}'; + END IF; + END $$; + """) + end + + def down do + execute("ALTER TABLE map_user_settings_v1 DROP COLUMN IF EXISTS ready_characters") + end +end diff --git a/priv/repo/migrations/20260209100000_add_intel_source_map_id.exs b/priv/repo/migrations/20260209100000_add_intel_source_map_id.exs new file mode 100644 index 000000000..b826981ef --- /dev/null +++ b/priv/repo/migrations/20260209100000_add_intel_source_map_id.exs @@ -0,0 +1,30 @@ +defmodule WandererApp.Repo.Migrations.AddIntelSourceMapId do + @moduledoc """ + Adds intel_source_map_id to maps_v1 for cross-map intel sharing. + A self-referencing FK that designates which map provides intel to this map. + """ + use Ecto.Migration + + def up do + alter table(:maps_v1) do + add :intel_source_map_id, + references(:maps_v1, + column: :id, + name: "maps_v1_intel_source_map_id_fkey", + type: :uuid, + on_delete: :nilify_all + ), + null: true + end + + create index(:maps_v1, [:intel_source_map_id]) + end + + def down do + drop_if_exists index(:maps_v1, [:intel_source_map_id]) + + alter table(:maps_v1) do + remove :intel_source_map_id + end + end +end diff --git a/priv/repo/migrations/20260209100001_add_inherited_from_map_id.exs b/priv/repo/migrations/20260209100001_add_inherited_from_map_id.exs new file mode 100644 index 000000000..21f45469e --- /dev/null +++ b/priv/repo/migrations/20260209100001_add_inherited_from_map_id.exs @@ -0,0 +1,44 @@ +defmodule WandererApp.Repo.Migrations.AddInheritedFromMapId do + @moduledoc """ + Adds inherited_from_map_id to comments and structures tables. + Tracks which records were copied from a source map during intel sync. + Records with this field set are read-only on the subscriber map. + """ + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + repo().query!( + "ALTER TABLE map_system_comments_v1 ADD COLUMN IF NOT EXISTS inherited_from_map_id uuid REFERENCES maps_v1(id) ON DELETE CASCADE", + [] + ) + + repo().query!( + "ALTER TABLE map_system_structures_v1 ADD COLUMN IF NOT EXISTS inherited_from_map_id uuid REFERENCES maps_v1(id) ON DELETE CASCADE", + [] + ) + + create_if_not_exists index(:map_system_comments_v1, [:inherited_from_map_id], + concurrently: true + ) + + create_if_not_exists index(:map_system_structures_v1, [:inherited_from_map_id], + concurrently: true + ) + end + + def down do + drop_if_exists index(:map_system_comments_v1, [:inherited_from_map_id], concurrently: true) + drop_if_exists index(:map_system_structures_v1, [:inherited_from_map_id], concurrently: true) + + alter table(:map_system_comments_v1) do + remove :inherited_from_map_id + end + + alter table(:map_system_structures_v1) do + remove :inherited_from_map_id + end + end +end diff --git a/priv/repo/migrations/20260331192521_add_mass_to_map_chain_passages.exs b/priv/repo/migrations/20260331192521_add_mass_to_map_chain_passages.exs index 7393767eb..3ba743ab8 100644 --- a/priv/repo/migrations/20260331192521_add_mass_to_map_chain_passages.exs +++ b/priv/repo/migrations/20260331192521_add_mass_to_map_chain_passages.exs @@ -9,7 +9,7 @@ defmodule WandererApp.Repo.Migrations.AddMassToMapChainPassages do def up do alter table(:maps_v1) do - modify :scopes, {:array, :text}, default: '{wormholes}' + modify :scopes, {:array, :text}, default: ~c"{wormholes}" end alter table(:map_chain_passages_v1) do diff --git a/priv/repo/migrations/20260406213852_add_character_description.exs b/priv/repo/migrations/20260406213852_add_character_description.exs index 6454b8fff..4ea4a2020 100644 --- a/priv/repo/migrations/20260406213852_add_character_description.exs +++ b/priv/repo/migrations/20260406213852_add_character_description.exs @@ -9,7 +9,7 @@ defmodule WandererApp.Repo.Migrations.AddCharacterDescription do def up do alter table(:maps_v1) do - modify :scopes, {:array, :text}, default: '{wormholes}' + modify :scopes, {:array, :text}, default: ~c"{wormholes}" end alter table(:character_v1) do diff --git a/priv/repo/migrations/20260801200000_fix_maps_scopes_default.exs b/priv/repo/migrations/20260801200000_fix_maps_scopes_default.exs new file mode 100644 index 000000000..f7b93dbbd --- /dev/null +++ b/priv/repo/migrations/20260801200000_fix_maps_scopes_default.exs @@ -0,0 +1,46 @@ +defmodule WandererApp.Repo.Migrations.FixMapsScopesDefault do + @moduledoc """ + Repairs the `maps_v1.scopes` column default. + + Migrations 20260331192521 and 20260406213852 declared the default as + `default: '{wormholes}'`. In Elixir a single-quoted literal is a charlist, + i.e. `[123, 119, 111, ...]`, so Ecto emitted an eleven-element text array of + the character codes of the literal string `{wormholes}` instead of the + one-element array `{wormholes}`. + + Any row inserted without an explicit `scopes` value therefore received + garbage that cannot be cast back to `{:array, :atom}`, and every subsequent + read of that row failed with `Ash.Error.Unknown` ("cannot load ... as type"). + + This migration sets the correct default and repairs rows already written with + the bad value. + """ + + use Ecto.Migration + + # Derived from the offending literal rather than transcribed by hand: a typo + # in a hardcoded list would make the WHERE clause match nothing and silently + # repair no rows. Both 20260331192521 and 20260406213852 used exactly this + # literal, so this single value catches rows written by either. + @bad_default Enum.map(~c"{wormholes}", &Integer.to_string/1) + + def up do + alter table(:maps_v1) do + modify :scopes, {:array, :text}, default: ["wormholes"] + end + + execute( + "UPDATE maps_v1 SET scopes = ARRAY['wormholes']::text[] WHERE scopes = ARRAY[#{Enum.map_join(@bad_default, ",", &"'#{&1}'")}]::text[]" + ) + end + + # Deliberately not a mirror image of up/0. Restoring the original charlist + # default would reintroduce the bug, so this drops the default instead, and + # rows already repaired stay repaired. Rolling back therefore leaves the + # schema in a different -- but correct -- state rather than the prior one. + def down do + alter table(:maps_v1) do + modify :scopes, {:array, :text}, default: nil + end + end +end diff --git a/priv/repo/migrations/20260801234058_add_map_discord_notifications.exs b/priv/repo/migrations/20260801234058_add_map_discord_notifications.exs new file mode 100644 index 000000000..8db609d03 --- /dev/null +++ b/priv/repo/migrations/20260801234058_add_map_discord_notifications.exs @@ -0,0 +1,55 @@ +defmodule WandererApp.Repo.Migrations.AddMapDiscordNotifications do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:map_discord_notifications_v1, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :enabled?, :boolean, null: false, default: true + add :wh_only, :boolean, null: false, default: true + add :excluded_systems, {:array, :bigint}, null: false, default: [] + add :last_delivery_at, :utc_datetime + add :last_error, :text + add :last_error_at, :utc_datetime + add :consecutive_failures, :bigint, null: false, default: 0 + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :map_id, + references(:maps_v1, + column: :id, + name: "map_discord_notifications_v1_map_id_fkey", + type: :uuid, + on_delete: :delete_all + ), + null: false + + add :encrypted_webhook_url, :binary, null: false + end + + create unique_index(:map_discord_notifications_v1, [:map_id], + name: "map_discord_notifications_v1_unique_map_id_index" + ) + end + + def down do + drop_if_exists unique_index(:map_discord_notifications_v1, [:map_id], + name: "map_discord_notifications_v1_unique_map_id_index" + ) + + drop constraint(:map_discord_notifications_v1, "map_discord_notifications_v1_map_id_fkey") + + drop table(:map_discord_notifications_v1) + end +end diff --git a/priv/repo/migrations/20260803202833_create_map_discord_webhooks.exs b/priv/repo/migrations/20260803202833_create_map_discord_webhooks.exs new file mode 100644 index 000000000..51c3ee30c --- /dev/null +++ b/priv/repo/migrations/20260803202833_create_map_discord_webhooks.exs @@ -0,0 +1,54 @@ +defmodule WandererApp.Repo.Migrations.CreateMapDiscordWebhooks do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:map_discord_webhooks_v1, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :role, :text, null: false + add :enabled?, :boolean, null: false, default: true + add :last_delivery_at, :utc_datetime + add :last_error, :text + add :last_error_at, :utc_datetime + add :consecutive_failures, :bigint, null: false, default: 0 + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :notification_id, + references(:map_discord_notifications_v1, + column: :id, + name: "map_discord_webhooks_v1_notification_id_fkey", + type: :uuid, + on_delete: :delete_all + ), + null: false + + add :encrypted_webhook_url, :binary, null: false + end + + create unique_index(:map_discord_webhooks_v1, [:notification_id, :role], + name: "map_discord_webhooks_v1_unique_notification_role_index" + ) + end + + def down do + drop_if_exists unique_index(:map_discord_webhooks_v1, [:notification_id, :role], + name: "map_discord_webhooks_v1_unique_notification_role_index" + ) + + drop constraint(:map_discord_webhooks_v1, "map_discord_webhooks_v1_notification_id_fkey") + + drop table(:map_discord_webhooks_v1) + end +end diff --git a/priv/repo/migrations/20260803210357_split_discord_webhooks.exs b/priv/repo/migrations/20260803210357_split_discord_webhooks.exs new file mode 100644 index 000000000..f82ab2e52 --- /dev/null +++ b/priv/repo/migrations/20260803210357_split_discord_webhooks.exs @@ -0,0 +1,183 @@ +defmodule WandererApp.Repo.Migrations.SplitDiscordWebhooks do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + + Hand-edited: the generator's drop/add for `map_discord_notifications_v1` is + unchanged, but a data-copy step has been inserted so existing rows aren't + destroyed, and the four unrelated resources it also picked up (maps_v1, + map_chain_v1, map_system_structures_v1, map_system_comments_v1 โ€” stale + `priv/resource_snapshots` drift, pre-existing and not part of this feature) + have been removed from both `up/0` and `down/0`. + """ + + use Ecto.Migration + + def up do + # Data step: give every existing notification a :system webhook carrying the + # URL and failure state it used to hold itself. + # + # The ciphertext is copied verbatim, no decrypt/re-encrypt round trip. + # AshCloak.do_encrypt/2 is + # value |> :erlang.term_to_binary() |> vault.encrypt!() |> Base.encode64() + # (deps/ash_cloak/lib/ash_cloak.ex:65-73). The resource is used only to pick + # a vault; neither the table nor the row identity enters the ciphertext, and + # the vault's AES-GCM uses fixed AAD. The bytes therefore decrypt correctly + # from the new table. Do not replace this with an application-level migration. + # + # `enabled?` is copied to BOTH rows. The old single flag conflated two + # meanings โ€” the user switching notifications off, and record_failure + # auto-disabling after ten consecutive failures โ€” and the migration cannot + # tell them apart retroactively. Copying it down is the conservative + # direction: a map that was silent before the upgrade stays silent after it, + # and no webhook starts posting because a migration guessed generously. The + # cost is that re-enabling a previously-disabled map also needs the + # destination re-enabled. Do NOT "simplify" this to enabled? = true. + # `"enabled?"` is double-quoted throughout: Postgres rejects `?` in an + # unquoted identifier (the column is declared as `enabled?` because Ash + # attribute names may end in `?`, but the underlying SQL identifier still + # needs quoting outside of Ecto/Ash-generated DDL). + # Guarded by `NOT EXISTS` against the child's real unique index + # (`map_discord_webhooks_v1_unique_notification_role_index` on + # `(notification_id, role)`): without it, any notification that already + # has a `:system` child โ€” a partially-applied deploy, a hand-repaired row, + # or simply re-running this migration โ€” aborts the whole statement on the + # first collision instead of skipping the rows already split. + # + # Wrapped in a DO block (rather than a bare `execute`) so a notification + # excluded by the guard can be *named* instead of silently losing its + # `encrypted_webhook_url` when the column is dropped below. `up/0` runs + # unattended during a deploy, so it must not abort here โ€” skipping is the + # correct, safe behavior, exactly as the guard already does โ€” but it must + # leave evidence in the deploy log. The skipped set is computed BEFORE the + # INSERT (capturing exactly the rows the guard is about to exclude) and + # reported via `RAISE WARNING` AFTER the INSERT has run, so what's printed + # reflects the final, settled state rather than a snapshot that a + # concurrent write could have invalidated. `RAISE WARNING` does not abort + # the enclosing transaction โ€” only `RAISE EXCEPTION` does. + execute(""" + DO $$ + DECLARE + already_split_ids text; + already_split_count int; + BEGIN + SELECT string_agg(n.id::text, ', '), count(*) + INTO already_split_ids, already_split_count + FROM map_discord_notifications_v1 n + WHERE n.encrypted_webhook_url IS NOT NULL + AND EXISTS ( + SELECT 1 FROM map_discord_webhooks_v1 w + WHERE w.notification_id = n.id AND w.role = 'system' + ); + + INSERT INTO map_discord_webhooks_v1 ( + id, notification_id, role, encrypted_webhook_url, "enabled?", + last_delivery_at, last_error, last_error_at, consecutive_failures, + inserted_at, updated_at + ) + SELECT + gen_random_uuid(), n.id, 'system', n.encrypted_webhook_url, n."enabled?", + n.last_delivery_at, n.last_error, n.last_error_at, n.consecutive_failures, + (now() AT TIME ZONE 'utc'), (now() AT TIME ZONE 'utc') + FROM map_discord_notifications_v1 n + WHERE NOT EXISTS ( + SELECT 1 FROM map_discord_webhooks_v1 w + WHERE w.notification_id = n.id AND w.role = 'system' + ); + + IF already_split_count > 0 THEN + RAISE WARNING 'split_discord_webhooks: % notification(s) already had a :system webhook; their parent encrypted_webhook_url was NOT migrated and is about to be dropped (ids: %)', already_split_count, already_split_ids; + END IF; + END $$; + """) + + alter table(:map_discord_notifications_v1) do + remove :encrypted_webhook_url + remove :consecutive_failures + remove :last_error_at + remove :last_error + remove :last_delivery_at + add :focus_corp_ids, {:array, :bigint}, null: false, default: [] + end + end + + def down do + alter table(:map_discord_notifications_v1) do + # Dropping this DISCARDS every configured focus corporation: the column + # did not exist before this migration, so there is nowhere to roll it + # back to. A later re-run of up/0 recreates it with the default `[]`. + # Expected and accepted โ€” focus corporations must be reconfigured after a + # rollback and re-apply. Unlike the webhook URL below, this is a + # preference, not a credential. + remove :focus_corp_ids + add :last_delivery_at, :utc_datetime + add :last_error, :text + add :last_error_at, :utc_datetime + add :consecutive_failures, :bigint, null: false, default: 0 + # 1. Restore the column WITHOUT the NOT NULL constraint. Codegen would + # have written `null: false` here (mirroring the resource's + # `allow_nil? false`); a NOT NULL column with no default added to a + # table that already has rows fails immediately, before the reverse + # copy below ever runs, aborting the rollback. + add :encrypted_webhook_url, :binary, null: true + end + + # 2. Copy each notification's :system destination back onto the parent. + execute(""" + UPDATE map_discord_notifications_v1 n + SET encrypted_webhook_url = w.encrypted_webhook_url, + "enabled?" = w."enabled?", + last_delivery_at = w.last_delivery_at, + last_error = w.last_error, + last_error_at = w.last_error_at, + consecutive_failures = w.consecutive_failures + FROM map_discord_webhooks_v1 w + WHERE w.notification_id = n.id AND w.role = 'system' + """) + + # 3. Delete the :system rows now that their data lives back on the parent. + # Without this, re-running `up/0` after a rollback (or rolling back + # twice) hits the child's unique (notification_id, role) identity: the + # old :system row is still there, so the INSERT in `up/0` collides with + # it. `:character` rows are untouched โ€” they were never derived from the + # parent and have nowhere to roll back to. + # Scoped with a join to the parent table (rather than a bare + # `WHERE role = 'system'`) to match exactly the rows the UPDATE above + # just restored, not every `:system` row in the database. + execute(""" + DELETE FROM map_discord_webhooks_v1 w + USING map_discord_notifications_v1 n + WHERE w.notification_id = n.id AND w.role = 'system' + """) + + # 4. Only now can the original constraint be reinstated. A notification with + # no :system child would fail here โ€” which is correct: it has no URL to + # roll back to, and silently leaving the column nullable would diverge + # from the pre-migration schema. Without the preflight below, that + # failure surfaces as Postgres' generic "column ... contains null + # values" error, which names neither the offending rows nor the fix. + # `down/0` is run by hand by an operator, so aborting here is correct โ€” + # unlike `up/0` above, there is no unattended deploy to keep moving โ€” + # but the abort must explain itself instead of leaving the operator to + # go spelunking. + execute(""" + DO $$ + DECLARE + unrestorable_ids text; + BEGIN + SELECT string_agg(id::text, ', ') INTO unrestorable_ids + FROM map_discord_notifications_v1 + WHERE encrypted_webhook_url IS NULL; + + IF unrestorable_ids IS NOT NULL THEN + RAISE EXCEPTION 'split_discord_webhooks rollback: notification(s) have no :system webhook to restore a webhook_url from (ids: %). Re-create a :system webhook for each, or delete these notification rows, then retry the rollback.', unrestorable_ids; + END IF; + END $$; + """) + + execute( + "ALTER TABLE map_discord_notifications_v1 ALTER COLUMN encrypted_webhook_url SET NOT NULL" + ) + end +end diff --git a/priv/repo/migrations/20260804180000_add_map_chain_locked_by_fkey.exs b/priv/repo/migrations/20260804180000_add_map_chain_locked_by_fkey.exs new file mode 100644 index 000000000..1a5db7642 --- /dev/null +++ b/priv/repo/migrations/20260804180000_add_map_chain_locked_by_fkey.exs @@ -0,0 +1,44 @@ +defmodule WandererApp.Repo.Migrations.AddMapChainLockedByFkey do + @moduledoc ~S""" + Creates the `map_chain_v1_locked_by_id_fkey` constraint that + `MapConnection`'s `belongs_to :locked_by` has always implied but no database + has ever had. + + `20260425000000_add_map_connection_locked_by.exs` added `locked_by_id` as a + bare `:binary_id` with no `references(...)`, so the column has been an + unenforced pointer since it was introduced. The resource snapshot declares + the constraint, which means every future `mix ash.codegen` run treats the + gap as already closed and will never re-surface it. + + Backfill is a no-op by construction: connection locking writes + `locked_by_id` only to the `map_#{map_id}:conn_#{id}:locked_info` cache entry + (`map_server_connections_impl.ex`), never to this column, so every row's + value is NULL. The migration is still written to fail loudly rather than + silently skip if that assumption is ever wrong on a deployment. + + `on_delete` is deliberately unset, matching the resource: destroying a + Character that holds a lock should raise rather than silently drop the + reference or the connection. + """ + use Ecto.Migration + + def up do + alter table(:map_chain_v1) do + modify :locked_by_id, + references(:character_v1, + column: :id, + name: "map_chain_v1_locked_by_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:map_chain_v1, "map_chain_v1_locked_by_id_fkey") + + alter table(:map_chain_v1) do + modify :locked_by_id, :uuid + end + end +end diff --git a/priv/repo/migrations/20260807203452_add_route_alert_config.exs b/priv/repo/migrations/20260807203452_add_route_alert_config.exs new file mode 100644 index 000000000..3e80fa9f6 --- /dev/null +++ b/priv/repo/migrations/20260807203452_add_route_alert_config.exs @@ -0,0 +1,25 @@ +defmodule WandererApp.Repo.Migrations.AddRouteAlertConfig do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:map_discord_notifications_v1) do + add :route_alerts_enabled?, :boolean, null: false, default: false + add :home_system_id, :bigint + add :route_max_jumps, :bigint, null: false, default: 5 + end + end + + def down do + alter table(:map_discord_notifications_v1) do + remove :route_max_jumps + remove :home_system_id + remove :route_alerts_enabled? + end + end +end diff --git a/priv/repo/migrations/20260807203453_add_webhook_mention_targets.exs b/priv/repo/migrations/20260807203453_add_webhook_mention_targets.exs new file mode 100644 index 000000000..080d5d176 --- /dev/null +++ b/priv/repo/migrations/20260807203453_add_webhook_mention_targets.exs @@ -0,0 +1,21 @@ +defmodule WandererApp.Repo.Migrations.AddWebhookMentionTargets do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:map_discord_webhooks_v1) do + add :mention_targets, {:array, :text}, null: false, default: [] + end + end + + def down do + alter table(:map_discord_webhooks_v1) do + remove :mention_targets + end + end +end diff --git a/priv/repo/migrations/20260808162801_add_discord_webhook_channel_info.exs b/priv/repo/migrations/20260808162801_add_discord_webhook_channel_info.exs new file mode 100644 index 000000000..dc3327e39 --- /dev/null +++ b/priv/repo/migrations/20260808162801_add_discord_webhook_channel_info.exs @@ -0,0 +1,23 @@ +defmodule WandererApp.Repo.Migrations.AddDiscordWebhookChannelInfo do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:map_discord_webhooks_v1) do + add :channel_id, :text + add :channel_label, :text + end + end + + def down do + alter table(:map_discord_webhooks_v1) do + remove :channel_label + remove :channel_id + end + end +end diff --git a/priv/repo/migrations/20260808172034_normalize_map_scopes_default.exs b/priv/repo/migrations/20260808172034_normalize_map_scopes_default.exs new file mode 100644 index 000000000..1f4d60028 --- /dev/null +++ b/priv/repo/migrations/20260808172034_normalize_map_scopes_default.exs @@ -0,0 +1,32 @@ +defmodule WandererApp.Repo.Migrations.NormalizeMapScopesDefault do + @moduledoc ~S""" + Re-records the `maps_v1.scopes` default in the resource snapshot so + `mix ash.codegen --check` stops reporting pending changes. + + `20260406213852_add_character_description.exs` wrote the default as the + charlist literal `'{wormholes}'`, which is how ash_postgres serialized array + defaults at the time. Current ash_postgres renders the same default as the + Elixir list `["wormholes"]`, so every codegen run since has seen a diff that + nobody committed. + + This is a no-op against the database. Both forms compile to the identical + column default (`ARRAY['wormholes']` and `'{wormholes}'` are the same + `text[]` value), and the type is unchanged, so Postgres takes a brief + ACCESS EXCLUSIVE lock without rewriting the table. It is committed only so + the snapshot on disk matches what codegen generates. + """ + + use Ecto.Migration + + def up do + alter table(:maps_v1) do + modify :scopes, {:array, :text}, default: ["wormholes"] + end + end + + def down do + alter table(:maps_v1) do + modify :scopes, {:array, :text}, default: ~c"{wormholes}" + end + end +end diff --git a/priv/repo/migrations/20260808215238_add_webhook_guild_identity.exs b/priv/repo/migrations/20260808215238_add_webhook_guild_identity.exs new file mode 100644 index 000000000..64ef482b2 --- /dev/null +++ b/priv/repo/migrations/20260808215238_add_webhook_guild_identity.exs @@ -0,0 +1,23 @@ +defmodule WandererApp.Repo.Migrations.AddWebhookGuildIdentity do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:map_discord_webhooks_v1) do + add :guild_id, :text + add :channel_label_source, :text + end + end + + def down do + alter table(:map_discord_webhooks_v1) do + remove :channel_label_source + remove :guild_id + end + end +end diff --git a/priv/resource_snapshots/repo/map_chain_v1/20260804163210.json b/priv/resource_snapshots/repo/map_chain_v1/20260804163210.json new file mode 100644 index 000000000..473d3ea28 --- /dev/null +++ b/priv/resource_snapshots/repo/map_chain_v1/20260804163210.json @@ -0,0 +1,272 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "solar_system_source", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "solar_system_target", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "mass_status", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "time_status", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "2", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ship_size_type", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "type", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "wormhole_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "count_of_passage", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "locked", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "locked_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "custom_info", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_chain_v1_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "map_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_chain_v1_locked_by_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "character_v1" + }, + "scale": null, + "size": null, + "source": "locked_by_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "map_id" + ], + "fields": [ + { + "type": "atom", + "value": "map_id" + } + ], + "include": null, + "message": null, + "name": "map_chain_v1_map_id_index", + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "8B935FEE67CE555ED653BA49ABA0A63245B437FDB136FB9952EB087CE4ED58E8", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_chain_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_notifications_v1/20260801234059.json b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260801234059.json new file mode 100644 index 000000000..2c680e4b4 --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260801234059.json @@ -0,0 +1,200 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "wh_only", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "excluded_systems", + "type": [ + "array", + "bigint" + ] + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_delivery_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "consecutive_failures", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_notifications_v1_map_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "map_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_webhook_url", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "28FA790E823D2C78153C94A6A5FCDDF298B77296E71057B12E1C63EC18B79053", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_notifications_v1_unique_map_id_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + } + ], + "name": "unique_map_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_notifications_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_notifications_v1/20260803210357.json b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260803210357.json new file mode 100644 index 000000000..ea3f780fc --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260803210357.json @@ -0,0 +1,155 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "wh_only", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "excluded_systems", + "type": [ + "array", + "bigint" + ] + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "focus_corp_ids", + "type": [ + "array", + "bigint" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_notifications_v1_map_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "960C91E0921D294FEFEAB2584970EFE683284EB9453B3C0F09458E32B524A216", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_notifications_v1_unique_map_id_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + } + ], + "name": "unique_map_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_notifications_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_notifications_v1/20260807203452.json b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260807203452.json new file mode 100644 index 000000000..6e4112faa --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_notifications_v1/20260807203452.json @@ -0,0 +1,191 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "wh_only", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "excluded_systems", + "type": [ + "array", + "bigint" + ] + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "focus_corp_ids", + "type": [ + "array", + "bigint" + ] + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "route_alerts_enabled?", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "home_system_id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "5", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "route_max_jumps", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_notifications_v1_map_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "C2F7B3D1C015B5F5FD23B467F62A71755AF4170097FBE2DB1034DBF229F40B08", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_notifications_v1_unique_map_id_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + } + ], + "name": "unique_map_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_notifications_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260803202833.json b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260803202833.json new file mode 100644 index 000000000..04113d16a --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260803202833.json @@ -0,0 +1,189 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "role", + "type": "text" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_delivery_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "consecutive_failures", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_webhooks_v1_notification_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "map_discord_notifications_v1" + }, + "scale": null, + "size": null, + "source": "notification_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_webhook_url", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "883023968CE7DD7C03F86AAB3B3D614C91A1046CD6E1C15015C3FCEBD1A9C517", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_webhooks_v1_unique_notification_role_index", + "keys": [ + { + "type": "atom", + "value": "notification_id" + }, + { + "type": "atom", + "value": "role" + } + ], + "name": "unique_notification_role", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_webhooks_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260807203453.json b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260807203453.json new file mode 100644 index 000000000..acbcf748e --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260807203453.json @@ -0,0 +1,204 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "role", + "type": "text" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_delivery_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "consecutive_failures", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "mention_targets", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_webhooks_v1_notification_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "map_discord_notifications_v1" + }, + "scale": null, + "size": null, + "source": "notification_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_webhook_url", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "C6A54545693E108BF3624F7E066F53F2BAE121AB17A1C2A04C4F4156C80C6434", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_webhooks_v1_unique_notification_role_index", + "keys": [ + { + "type": "atom", + "value": "notification_id" + }, + { + "type": "atom", + "value": "role" + } + ], + "name": "unique_notification_role", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_webhooks_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260808162802.json b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260808162802.json new file mode 100644 index 000000000..cf621845d --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260808162802.json @@ -0,0 +1,228 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "role", + "type": "text" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_delivery_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "consecutive_failures", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "channel_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "channel_label", + "type": "text" + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "mention_targets", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_webhooks_v1_notification_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "map_discord_notifications_v1" + }, + "scale": null, + "size": null, + "source": "notification_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_webhook_url", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "290EA66510EFFD48CB7F7C1FD48D3DA18B69E79AF57C18004FAD075AA2082BB5", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_webhooks_v1_unique_notification_role_index", + "keys": [ + { + "type": "atom", + "value": "notification_id" + }, + { + "type": "atom", + "value": "role" + } + ], + "name": "unique_notification_role", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_webhooks_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260808215238.json b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260808215238.json new file mode 100644 index 000000000..9491d6e46 --- /dev/null +++ b/priv/resource_snapshots/repo/map_discord_webhooks_v1/20260808215238.json @@ -0,0 +1,252 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "role", + "type": "text" + }, + { + "allow_nil?": false, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "enabled?", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_delivery_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_error_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "consecutive_failures", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "channel_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "channel_label", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "guild_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "channel_label_source", + "type": "text" + }, + { + "allow_nil?": false, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "mention_targets", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_discord_webhooks_v1_notification_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "map_discord_notifications_v1" + }, + "scale": null, + "size": null, + "source": "notification_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "encrypted_webhook_url", + "type": "binary" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "CC7D7D6EDA8FBF453E1E2BD4E0BE90695649CAFAE5A7DA5A2B938EDB392719A6", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_discord_webhooks_v1_unique_notification_role_index", + "keys": [ + { + "type": "atom", + "value": "notification_id" + }, + { + "type": "atom", + "value": "role" + } + ], + "name": "unique_notification_role", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_discord_webhooks_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_system_comments_v1/20260804163210.json b/priv/resource_snapshots/repo/map_system_comments_v1/20260804163210.json new file mode 100644 index 000000000..818591575 --- /dev/null +++ b/priv/resource_snapshots/repo/map_system_comments_v1/20260804163210.json @@ -0,0 +1,160 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "text", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_comments_v1_system_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "map_system_v1" + }, + "scale": null, + "size": null, + "source": "system_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_comments_v1_character_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "character_v1" + }, + "scale": null, + "size": null, + "source": "character_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_comments_v1_inherited_from_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "inherited_from_map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "2D6A1626943F0951F5E548945CEEAAE5F51D792C24F57E928BACD28E821FD49A", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_system_comments_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_system_structures_v1/20260804163210.json b/priv/resource_snapshots/repo/map_system_structures_v1/20260804163210.json new file mode 100644 index 000000000..b6fb8d231 --- /dev/null +++ b/priv/resource_snapshots/repo/map_system_structures_v1/20260804163210.json @@ -0,0 +1,261 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "structure_type_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "structure_type", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "character_eve_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "solar_system_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "solar_system_id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "owner_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "owner_ticker", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "owner_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "end_time", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_structures_v1_system_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "map_system_v1" + }, + "scale": null, + "size": null, + "source": "system_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_structures_v1_inherited_from_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "inherited_from_map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F6075C7603C645967BB489806BCE4BB1CC20CD25E74EACEBFD32428EBCA757C6", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_system_structures_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_system_v1/20250123000351.json b/priv/resource_snapshots/repo/map_system_v1/20250123000351.json new file mode 100644 index 000000000..63cdfcdf4 --- /dev/null +++ b/priv/resource_snapshots/repo/map_system_v1/20250123000351.json @@ -0,0 +1,257 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "solar_system_id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "custom_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "tag", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "temporary_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "owner_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "owner_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "labels", + "type": "text" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "status", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "visible", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "locked", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "position_x", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "position_y", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "added_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "linked_sig_eve_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_v1_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "maps_v1" + }, + "size": null, + "source": "map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "9D25F910B0AE29C37011D75063F1011EB0E649AA333AE83100312CC0960C4D5D", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_system_v1_map_solar_system_id_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + }, + { + "type": "atom", + "value": "solar_system_id" + } + ], + "name": "map_solar_system_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_system_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_system_v1/20250307165740.json b/priv/resource_snapshots/repo/map_system_v1/20250307165740.json new file mode 100644 index 000000000..54c0d455d --- /dev/null +++ b/priv/resource_snapshots/repo/map_system_v1/20250307165740.json @@ -0,0 +1,277 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "solar_system_id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "custom_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "tag", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "temporary_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "owner_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "owner_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "owner_ticker", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "custom_flags", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "labels", + "type": "text" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "status", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "visible", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "locked", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "position_x", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "position_y", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "added_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "linked_sig_eve_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_v1_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "maps_v1" + }, + "size": null, + "source": "map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E76290EF8E4A4C824ABF72037DE34FA705096DF929D71A73D43150606224DC6F", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_system_v1_map_solar_system_id_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + }, + { + "type": "atom", + "value": "solar_system_id" + } + ], + "name": "map_solar_system_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_system_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_system_v1/20251130211523.json b/priv/resource_snapshots/repo/map_system_v1/20251130211523.json new file mode 100644 index 000000000..9eea50b2c --- /dev/null +++ b/priv/resource_snapshots/repo/map_system_v1/20251130211523.json @@ -0,0 +1,344 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "solar_system_id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "custom_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "tag", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "temporary_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "owner_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "owner_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "owner_ticker", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "custom_flags", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "labels", + "type": "text" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "true", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "visible", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "locked", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "position_x", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "0", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "position_y", + "type": "bigint" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "added_at", + "type": "utc_datetime" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "linked_sig_eve_id", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_system_v1_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "map_id" + ], + "fields": [ + { + "type": "atom", + "value": "map_id" + } + ], + "include": null, + "message": null, + "name": "map_system_v1_map_id_visible_index", + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": "visible = true" + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "6816F0D083880168CF924671CF48364910723A88E3256430E2919612551681D8", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_system_v1_map_solar_system_id_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + }, + { + "type": "atom", + "value": "solar_system_id" + } + ], + "name": "map_solar_system_id", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_system_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_user_settings_v1/20250625024813.json b/priv/resource_snapshots/repo/map_user_settings_v1/20250625024813.json new file mode 100644 index 000000000..12936a6c7 --- /dev/null +++ b/priv/resource_snapshots/repo/map_user_settings_v1/20250625024813.json @@ -0,0 +1,162 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "settings", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "main_character_eve_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "following_character_eve_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "ready_characters", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "hubs", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_user_settings_v1_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "maps_v1" + }, + "size": null, + "source": "map_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_user_settings_v1_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "user_v1" + }, + "size": null, + "source": "user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "05C600B7F639C7611E1348482F7AB6F1015291B03A23C9E3E363C63665392928", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_user_settings_v1_uniq_map_user_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + }, + { + "type": "atom", + "value": "user_id" + } + ], + "name": "uniq_map_user", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_user_settings_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/map_user_settings_v1/20251130211523.json b/priv/resource_snapshots/repo/map_user_settings_v1/20251130211523.json new file mode 100644 index 000000000..06f2ef50f --- /dev/null +++ b/priv/resource_snapshots/repo/map_user_settings_v1/20251130211523.json @@ -0,0 +1,178 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "settings", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "main_character_eve_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "following_character_eve_id", + "type": "text" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "ready_characters", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hubs", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_user_settings_v1_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "map_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "map_user_settings_v1_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "user_v1" + }, + "scale": null, + "size": null, + "source": "user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "C5EE8CA125E46DBBDC808199F9FF1191B2A0E71C76403A37A9EEDAC4EE450C52", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "map_user_settings_v1_uniq_map_user_index", + "keys": [ + { + "type": "atom", + "value": "map_id" + }, + { + "type": "atom", + "value": "user_id" + } + ], + "name": "uniq_map_user", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "map_user_settings_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/maps_v1/20251130211523.json b/priv/resource_snapshots/repo/maps_v1/20251130211523.json new file mode 100644 index 000000000..d1b505e07 --- /dev/null +++ b/priv/resource_snapshots/repo/maps_v1/20251130211523.json @@ -0,0 +1,262 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "personal_note", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "public_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hubs", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "\"wormholes\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scope", + "type": "text" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "deleted", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "only_tracked_characters", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "options", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "webhooks_enabled", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "sse_enabled", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "maps_v1_owner_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "character_v1" + }, + "scale": null, + "size": null, + "source": "owner_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "4B460F04EF9942AA68978EE90FB94B9BE250D59D4376B7E11A46AC29DA93C90A", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_public_api_key_index", + "keys": [ + { + "type": "atom", + "value": "public_api_key" + } + ], + "name": "unique_public_api_key", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "maps_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/maps_v1/20260804163210.json b/priv/resource_snapshots/repo/maps_v1/20260804163210.json new file mode 100644 index 000000000..eb3d7976f --- /dev/null +++ b/priv/resource_snapshots/repo/maps_v1/20260804163210.json @@ -0,0 +1,308 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "personal_note", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "public_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hubs", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "\"wormholes\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scope", + "type": "text" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "deleted", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "only_tracked_characters", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "options", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "webhooks_enabled", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "sse_enabled", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "'{wormholes}'", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scopes", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "maps_v1_owner_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "character_v1" + }, + "scale": null, + "size": null, + "source": "owner_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "maps_v1_intel_source_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "intel_source_map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "E21753A30A2436F81FDE972E39A0899F4D4707C95A3D656ABEF776FF3CE79B4E", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_public_api_key_index", + "keys": [ + { + "type": "atom", + "value": "public_api_key" + } + ], + "name": "unique_public_api_key", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "maps_v1" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/maps_v1/20260808172034.json b/priv/resource_snapshots/repo/maps_v1/20260808172034.json new file mode 100644 index 000000000..afe388a65 --- /dev/null +++ b/priv/resource_snapshots/repo/maps_v1/20260808172034.json @@ -0,0 +1,308 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "personal_note", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "public_api_key", + "type": "text" + }, + { + "allow_nil?": true, + "default": "[]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "hubs", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "\"wormholes\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scope", + "type": "text" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "deleted", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "only_tracked_characters", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "options", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "webhooks_enabled", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "sse_enabled", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "[\"wormholes\"]", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "scopes", + "type": [ + "array", + "text" + ] + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "maps_v1_owner_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "character_v1" + }, + "scale": null, + "size": null, + "source": "owner_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "maps_v1_intel_source_map_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": null, + "table": "maps_v1" + }, + "scale": null, + "size": null, + "source": "intel_source_map_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "28EFCF19A87D6AF229DF5E7E0F8921087D834DB01EE8505F5A018BDDF91030DE", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_public_api_key_index", + "keys": [ + { + "type": "atom", + "value": "public_api_key" + } + ], + "name": "unique_public_api_key", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "maps_v1_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.WandererApp.Repo", + "schema": null, + "table": "maps_v1" +} \ No newline at end of file diff --git a/rel/env.sh.eex b/rel/env.sh.eex index b286c4c6c..2841457de 100755 --- a/rel/env.sh.eex +++ b/rel/env.sh.eex @@ -1,6 +1,5 @@ #!/bin/sh -# export ERL_AFLAGS="-proto_dist inet6_tcp" export RELEASE_DISTRIBUTION="name" # Use custom RELEASE_NODE if set, otherwise detect environment @@ -10,6 +9,13 @@ if [ -n "$RELEASE_NODE" ]; then elif [ -n "$FLY_APP_NAME" ] && [ -n "$FLY_IMAGE_REF" ] && [ -n "$FLY_PRIVATE_IP" ]; then # Fly.io environment detected export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}" + # FLY_PRIVATE_IP is an IPv6 6PN literal, so distribution must use the IPv6 + # protocol driver or the VM cannot resolve its own node name. + # ee15d90f9 ("fix: removed ipv6 distribution env settings") removed this + # flag from the top of the file; do not remove it again โ€” it is required + # for this branch specifically, not for the generic/docker-compose branch + # below. + export ERL_AFLAGS="-proto_dist inet6_tcp" else # Generic deployment - use hostname export RELEASE_NODE="wanderer@$(hostname)" diff --git a/test/integration/map/map_scope_filtering_test.exs b/test/integration/map/map_scope_filtering_test.exs index 176b885b3..9ad35cd60 100644 --- a/test/integration/map/map_scope_filtering_test.exs +++ b/test/integration/map/map_scope_filtering_test.exs @@ -213,6 +213,17 @@ defmodule WandererApp.Map.MapScopeFilteringTest do to_solar_system_id: @ls_system_halmah }) + # These "jump_*" keys live in the global cache and are never invalidated + # once written -- CachedInfo.get_solar_system_jump/2 only rebuilds the index + # when a key is missing. MapScopesTest asserts on the same system IDs + # (30_000_001 / 30_000_002 / 30_000_100) expecting NO stargate, so leaving + # these behind made its "valid when no stargate exists" cases fail depending + # on whether this suite happened to run first. + on_exit(fn -> + WandererApp.Cache.delete(halenan_mili_key) + WandererApp.Cache.delete(halenan_halmah_key) + end) + :ok end diff --git a/test/integration/map_duplication_api_controller_success_test.exs b/test/integration/map_duplication_api_controller_success_test.exs index a783fb4ae..c87029b86 100644 --- a/test/integration/map_duplication_api_controller_success_test.exs +++ b/test/integration/map_duplication_api_controller_success_test.exs @@ -237,6 +237,60 @@ defmodule WandererAppWeb.MapDuplicationAPIControllerSuccessTest do assert stargate_connection.solar_system_source == 30_000_142 assert stargate_connection.solar_system_target == 30_000_144 end + + test "duplicated map contains copied signatures when copy_signatures is true", %{ + conn: conn, + source_map: source_map + } do + duplication_params = %{ + "name" => "Signature Copy Test", + "copy_signatures" => true + } + + conn = post(conn, ~p"/api/maps/#{source_map.slug}/duplicate", duplication_params) + + assert %{"data" => %{"id" => new_map_id}} = json_response(conn, 201) + + # The signature belongs to a system, not the map, so resolve the copied + # Jita system first and then read its signatures. + {:ok, new_systems} = WandererApp.Api.MapSystem.read_all_by_map(%{map_id: new_map_id}) + new_jita = Enum.find(new_systems, &(&1.name == "Jita")) + assert new_jita != nil + + {:ok, new_signatures} = WandererApp.Api.MapSystemSignature.by_system_id_all(new_jita.id) + + # Previously `get_all_map_signatures/2` called `by_system_id_all/1` with a + # map instead of a bare id and swallowed the resulting error into `[]`, so + # duplication reported success with zero signatures copied. + assert length(new_signatures) == 1 + + copied = hd(new_signatures) + assert copied.eve_id == "ABC-123" + assert copied.name == "Test Wormhole" + assert copied.type == "wormhole" + assert copied.system_id == new_jita.id + end + + test "duplicated map has no signatures when copy_signatures is false", %{ + conn: conn, + source_map: source_map + } do + duplication_params = %{ + "name" => "Signature Skip Test", + "copy_signatures" => false + } + + conn = post(conn, ~p"/api/maps/#{source_map.slug}/duplicate", duplication_params) + + assert %{"data" => %{"id" => new_map_id}} = json_response(conn, 201) + + {:ok, new_systems} = WandererApp.Api.MapSystem.read_all_by_map(%{map_id: new_map_id}) + new_jita = Enum.find(new_systems, &(&1.name == "Jita")) + assert new_jita != nil + + {:ok, new_signatures} = WandererApp.Api.MapSystemSignature.by_system_id_all(new_jita.id) + assert new_signatures == [] + end end describe "error handling for map duplication" do diff --git a/test/support/discord_http_stub.ex b/test/support/discord_http_stub.ex new file mode 100644 index 000000000..6e3cb8e54 --- /dev/null +++ b/test/support/discord_http_stub.ex @@ -0,0 +1,83 @@ +defmodule WandererApp.ExternalEvents.Discord.HttpStub do + @moduledoc """ + Test double for Discord HTTP delivery. + + State lives in ONE named Agent shared by every test, so any test using this + stub must be `async: false`. Call `start/0` in setup (it resets the state if + the Agent is already up), `set_responses/1` to script replies, and + `requests/0` to assert on what was sent. + """ + @behaviour WandererApp.ExternalEvents.Discord.HttpClient + + @agent __MODULE__.Agent + + def start do + # `Agent.start`, not `start_link`: linking would tie the shared Agent to + # whichever test process happened to call `start/0` first, so it would die + # with that test. A worker Task still in flight afterwards would then hit a + # dead Agent, exit `:noproc`, and record a spurious delivery failure instead + # of the scripted response. + case Agent.start(fn -> new_state() end, name: @agent) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> reset() && {:ok, pid} + {:error, reason} -> raise "could not start #{inspect(@agent)}: #{inspect(reason)}" + end + end + + def reset, do: Agent.update(@agent, fn _ -> new_state() end) == :ok + + defp new_state, do: %{responses: [], by_url: %{}, requests: [], gets: %{}} + + @doc "Queues responses for ANY url, consumed in order. Each is {:ok, status, headers} or {:error, term}." + def set_responses(responses), do: Agent.update(@agent, &%{&1 | responses: responses}) + + @doc """ + Queues responses for ONE url, consumed in order and checked before the global + queue. Needed once a map has two webhooks: with a single global queue the two + destinations race for the scripted reply, so "404 the character webhook" would + land on whichever request happened to arrive first. + """ + def set_responses_for(url, responses), + do: Agent.update(@agent, &%{&1 | by_url: Map.put(&1.by_url, url, responses)}) + + @doc "Returns {url, body} tuples in the order they were sent." + def requests, do: Agent.get(@agent, & &1.requests) |> Enum.reverse() + + @doc "Returns the {url, body} tuples sent to one url, in order." + def requests_for(url), do: Enum.filter(requests(), fn {u, _body} -> u == url end) + + @doc """ + Scripts a reply for one GET url as `{:ok, status, body}` or `{:error, term}`. + + Unscripted GETs return `{:error, :not_stubbed}` rather than a success: every + test that renders the notifications settings tab reaches `ChannelInfo` + incidentally, and those tests assert on the tab, not on Discord. An error is + the input that exercises the masked-hint fallback, which is what an offline + test environment genuinely is. + """ + def set_get_response(url, response), + do: Agent.update(@agent, &%{&1 | gets: Map.put(&1.gets, url, response)}) + + @impl true + def get(url, _headers) do + Agent.get(@agent, &Map.get(&1.gets, url, {:error, :not_stubbed})) + end + + @impl true + def post(url, body) do + Agent.get_and_update(@agent, fn state -> + state = %{state | requests: [{url, body} | state.requests]} + + case Map.get(state.by_url, url) do + [resp | rest] -> + {resp, %{state | by_url: Map.put(state.by_url, url, rest)}} + + _ -> + case state.responses do + [] -> {{:ok, 204, []}, state} + [resp | rest] -> {resp, %{state | responses: rest}} + end + end + end) + end +end diff --git a/test/support/esi_offline_stub.ex b/test/support/esi_offline_stub.ex new file mode 100644 index 000000000..b9989f0ac --- /dev/null +++ b/test/support/esi_offline_stub.ex @@ -0,0 +1,23 @@ +defmodule WandererApp.Esi.OfflineStub do + @moduledoc """ + Default ESI seam for the test suite: answers every lookup with an error. + + Set as `:esi_client` in `config/test.exs` so no test reaches the real ESI over + the network by accident. The seam is read by the Discord enrichers + (`Discord.NotableItems`, `Discord.CorpTickers`), both of which fail open, so + an offline answer simply means "not enriched" โ€” which is what an unconfigured + test wants. + + Tests that care about enrichment override `:esi_client` with + `WandererApp.Esi.Mock` and script the calls they expect. + + Deliberately silent: the enrichers only log when a lookup *raises*, so the + default path here adds no noise to unrelated tests. + """ + + def get_character_info(_id, _opts \\ []), do: {:error, :esi_disabled_in_test} + def get_corporation_info(_id, _opts \\ []), do: {:error, :esi_disabled_in_test} + def get_alliance_info(_id, _opts \\ []), do: {:error, :esi_disabled_in_test} + def get_killmail(_id, _hash, _opts \\ []), do: {:error, :esi_disabled_in_test} + def get_type_info(_id, _opts \\ []), do: {:error, :esi_disabled_in_test} +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 8ed5efdbb..377f0adf6 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -879,4 +879,102 @@ defmodule WandererAppWeb.Factory do {:error, reason} -> raise "Failed to create solar system: #{inspect(reason)}" end end + + @doc """ + Builds killmail-shaped test payloads. + + * `:killmail` โ€” a flattened killmail map matching the output of + `WandererApp.Kills.MessageHandler.adapt_nested_format_kill/1` + * `:kill_event` โ€” the `:killmail_update` batch wrapper + * `:kill_count_event` โ€” the `:kill_count` wrapper, which carries no killmails + + All keys are strings, mirroring the real payload. + """ + def build(type, attrs \\ %{}) + + def build(:killmail, attrs) do + defaults = %{ + "killmail_id" => System.unique_integer([:positive]), + # Deliberately "now" rather than a fixed timestamp: the Discord dispatcher + # drops killmails older than `discord_max_killmail_age_seconds` + # (default 3600), so a hardcoded date would eventually go stale and start + # failing every delivery assertion in this suite. + "kill_time" => DateTime.utc_now() |> DateTime.to_iso8601(), + "solar_system_id" => 31_000_005, + "zkb" => %{}, + "victim_char_id" => 90_000_001, + "victim_char_name" => "Test Victim", + "victim_corp_id" => 98_000_001, + "victim_corp_ticker" => "TSTC", + "victim_corp_name" => "Test Corp", + "victim_alliance_id" => nil, + "victim_alliance_ticker" => nil, + "victim_alliance_name" => nil, + "victim_ship_type_id" => 626, + "victim_ship_name" => "Vexor", + "final_blow_char_id" => 90_000_002, + "final_blow_char_name" => "Test Attacker", + "final_blow_corp_id" => 98_000_002, + "final_blow_corp_ticker" => "ATKC", + "final_blow_corp_name" => "Attacker Corp", + "final_blow_alliance_id" => nil, + "final_blow_alliance_ticker" => nil, + "final_blow_alliance_name" => nil, + "final_blow_ship_type_id" => 621, + "final_blow_ship_name" => "Caracal", + "attacker_count" => 3, + # `add_attacker_identity_data/2` attaches these to every nested payload + # that carried an "attackers" list, so a faithful fixture has them. + # Their ABSENCE is meaningful โ€” it is what tells `Discord.Matcher` that + # involvement could not be determined โ€” so a fixture that omitted them + # made every routing test exercise the `:unknown` path by accident. + # Tests that want the unknown path must drop these keys explicitly. + "attacker_char_ids" => [], + "attacker_corp_ids" => [], + "total_value" => 84_000_000, + "npc" => false + } + + Map.merge(defaults, stringify_keys(attrs)) + end + + # `:killmail_update` โ€” the batch wrapper that + # `ExternalEvents.broadcast(map_id, :map_kill, payload)` actually receives. + def build(:kill_event, attrs) do + attrs = stringify_keys(attrs) + system_id = Map.get(attrs, "solar_system_id", 31_000_005) + + killmails = + Map.get(attrs, "killmails", [build(:killmail, %{"solar_system_id" => system_id})]) + + %{ + "solar_system_id" => system_id, + "killmails" => killmails, + "timestamp" => "2026-08-01T12:00:00Z", + "type" => :killmail_update + } + # Merge last so callers can override `timestamp` / `type` the same way they + # already override `solar_system_id` and `killmails`; both of those are + # derived from `attrs` above, so re-merging them is a no-op. + |> Map.merge(attrs) + end + + # `:kill_count` โ€” the second `:map_kill` shape, which carries no killmails + # and must be ignored by Discord delivery. + def build(:kill_count_event, attrs) do + attrs = stringify_keys(attrs) + + %{ + "solar_system_id" => Map.get(attrs, "solar_system_id", 31_000_005), + "count" => Map.get(attrs, "count", 5), + "type" => :kill_count + } + end + + defp stringify_keys(map) do + Map.new(map, fn + {k, v} when is_atom(k) -> {Atom.to_string(k), v} + {k, v} -> {k, v} + end) + end end diff --git a/test/support/mock_definitions.ex b/test/support/mock_definitions.ex index ba40b978c..1396dec9a 100644 --- a/test/support/mock_definitions.ex +++ b/test/support/mock_definitions.ex @@ -115,6 +115,14 @@ if Mix.env() == :test do @callback create!(any()) :: map() end + # Define websocket_client adapter mock behaviour (stands in for the + # `:websocket_client` Erlang module so tests can observe the arguments + # WandererApp.Kills.Transport.WebSocketClient passes to it). + defmodule WandererApp.Kills.Transport.WebSocketClientAdapter.MockBehaviour do + @callback start_link(charlist(), module(), list(), keyword()) :: + {:ok, pid()} | {:error, any()} + end + # Define ESI mock behaviour defmodule WandererApp.Esi.MockBehaviour do @callback get_character_info(binary()) :: {:ok, map()} | {:error, any()} @@ -123,6 +131,16 @@ if Mix.env() == :test do @callback get_corporation_info(binary(), keyword()) :: {:ok, map()} | {:error, any()} @callback get_alliance_info(binary()) :: {:ok, map()} | {:error, any()} @callback get_alliance_info(binary(), keyword()) :: {:ok, map()} | {:error, any()} + @callback get_killmail(binary() | integer(), binary()) :: {:ok, map()} | {:error, any()} + + @callback get_killmail(binary() | integer(), binary(), keyword()) :: + {:ok, map()} | {:error, any()} + + @callback get_type_info(binary() | integer()) :: {:ok, map()} | {:error, any()} + @callback get_type_info(binary() | integer(), keyword()) :: {:ok, map()} | {:error, any()} + + @callback get_routes_custom([integer()], integer(), map()) :: {:ok, [map()]} | {:error, any()} + @callback get_routes_eve([integer()], integer(), map(), keyword()) :: {:ok, [map()]} end # Define all the mocks @@ -155,4 +173,14 @@ if Mix.env() == :test do Mox.defmock(Test.TelemetryMock, for: Test.TelemetryMock.MockBehaviour) Mox.defmock(Test.AshMock, for: Test.AshMock.MockBehaviour) Mox.defmock(WandererApp.Esi.Mock, for: WandererApp.Esi.MockBehaviour) + + Mox.defmock(Test.WebSocketClientMock, + for: WandererApp.Kills.Transport.WebSocketClientAdapter.MockBehaviour + ) + + # The Discord HTTP seam. `config/test.exs` points `:discord_http_client` at + # `HttpStub` for the delivery tests, which need a shared scripted queue; this + # mock is for tests that want per-call expectations instead, and swaps the + # config for their duration. + Mox.defmock(Test.DiscordHttpClientMock, for: WandererApp.ExternalEvents.Discord.HttpClient) end diff --git a/test/support/openapi_spec_analyzer.ex b/test/support/openapi_spec_analyzer.ex index dbdb81dd1..8de47ce5c 100644 --- a/test/support/openapi_spec_analyzer.ex +++ b/test/support/openapi_spec_analyzer.ex @@ -155,7 +155,7 @@ defmodule WandererAppWeb.OpenAPISpecAnalyzer do # Categorize schemas based on naming patterns request_schemas = Enum.filter(schema_names, &String.contains?(&1, "Request")) response_schemas = Enum.filter(schema_names, &String.contains?(&1, "Response")) - shared_schemas = schema_names -- (request_schemas -- response_schemas) + shared_schemas = schema_names -- request_schemas -- response_schemas %{ total: length(schema_names), diff --git a/test/support/triff_http_stub.ex b/test/support/triff_http_stub.ex new file mode 100644 index 000000000..7cc83ae95 --- /dev/null +++ b/test/support/triff_http_stub.ex @@ -0,0 +1,57 @@ +defmodule WandererApp.Market.Triff.HttpStub do + @moduledoc """ + Test double for triff.tools HTTP calls. + + State lives in ONE named Agent shared by every test, so any test using this + stub must be `async: false`. Call `start/0` in setup (it resets the state if + the Agent is already up), `set_responses/1` to script replies, and + `requests/0` to assert on what was requested. + + Mirrors `WandererApp.ExternalEvents.Discord.HttpStub`. + """ + @behaviour WandererApp.Market.Triff.HttpClient + + @agent __MODULE__.Agent + + def start do + # `Agent.start`, not `start_link`: linking would tie the shared Agent to + # whichever test process happened to call `start/0` first, so it would die + # with that test, and an enrichment task still in flight would then hit a + # dead Agent and exit `:noproc` instead of getting the scripted response. + case Agent.start(fn -> new_state() end, name: @agent) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> reset() && {:ok, pid} + {:error, reason} -> raise "could not start #{inspect(@agent)}: #{inspect(reason)}" + end + end + + def reset, do: Agent.update(@agent, fn _ -> new_state() end) == :ok + + defp new_state, do: %{responses: [], requests: []} + + @doc """ + Queues responses, consumed in order. Each is `{:ok, status, body}` or + `{:error, term}`. A body given as a map is JSON-encoded for you. When the + queue runs dry the stub returns an empty `types` list rather than raising, so + a test only has to script the calls it cares about. + """ + def set_responses(responses), do: Agent.update(@agent, &%{&1 | responses: responses}) + + @doc "Returns the requested urls, in the order they were sent." + def requests, do: Agent.get(@agent, & &1.requests) |> Enum.reverse() + + @impl true + def get(url, _headers) do + Agent.get_and_update(@agent, fn state -> + state = %{state | requests: [url | state.requests]} + + case state.responses do + [] -> {{:ok, 200, ~s({"types":[]})}, state} + [resp | rest] -> {encode(resp), %{state | responses: rest}} + end + end) + end + + defp encode({:ok, status, body}) when is_map(body), do: {:ok, status, Jason.encode!(body)} + defp encode(resp), do: resp +end diff --git a/test/unit/api/map_discord_notification_test.exs b/test/unit/api/map_discord_notification_test.exs new file mode 100644 index 000000000..b6dfac6dd --- /dev/null +++ b/test/unit/api/map_discord_notification_test.exs @@ -0,0 +1,329 @@ +defmodule WandererApp.Api.MapDiscordNotificationTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.Api.MapDiscordWebhook + alias WandererApp.ExternalEvents.Discord.{Worker, WorkerSupervisor} + alias WandererAppWeb.Factory + + defp valid_url, do: "https://discord.com/api/webhooks/123456789/abcdefTOKEN" + + # The dispatcher's per-map config cache, seeded and read directly so these + # tests do not depend on the dispatcher GenServer running. + @cache :discord_notification_cache + + defp await_condition(fun, timeout \\ 2_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_await_condition(fun, deadline) + end + + defp do_await_condition(fun, deadline) do + case fun.() do + {:ok, value} -> + value + + :retry -> + if System.monotonic_time(:millisecond) > deadline do + flunk("condition not met before deadline") + else + Process.sleep(25) + do_await_condition(fun, deadline) + end + end + end + + setup do + map = Factory.insert(:map, %{}) + %{map: map} + end + + test "creates with policy defaults", %{map: map} do + assert {:ok, rec} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert rec.enabled? == true + assert rec.wh_only == true + assert rec.excluded_systems == [] + assert rec.focus_corp_ids == [] + end + + test "no longer carries webhook_url or failure state", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + refute Map.has_key?(rec, :webhook_url) + refute Map.has_key?(rec, :consecutive_failures) + refute Map.has_key?(rec, :last_error) + refute Map.has_key?(rec, :last_error_at) + refute Map.has_key?(rec, :last_delivery_at) + end + + test "focus_corp_ids round-trips", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, rec} = + MapDiscordNotification.update(rec, %{focus_corp_ids: [98_000_001, 98_000_002]}) + + assert rec.focus_corp_ids == [98_000_001, 98_000_002] + + assert {:ok, reloaded} = MapDiscordNotification.by_map(map.id) + assert reloaded.focus_corp_ids == [98_000_001, 98_000_002] + end + + test "create makes the parent and its :system webhook in one transaction", %{map: map} do + assert {:ok, rec} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, [hook]} = MapDiscordWebhook.by_notification(rec.id) + assert hook.role == :system + assert hook.webhook_url == valid_url() + end + + test "a rejected webhook url leaves no parent row behind", %{map: map} do + # The invariant "a system webhook always exists" is transactional, not + # declarative โ€” the unique identity gives at most one webhook per role, never + # at least one. If the child create fails the parent must roll back, or the + # map is left with a policy row and nowhere to deliver. + assert {:error, _} = + MapDiscordNotification.create(%{ + map_id: map.id, + webhook_url: "https://evil.example.com/api/webhooks/1/tok" + }) + + assert {:error, _} = MapDiscordNotification.by_map(map.id) + end + + test "by_map loads the webhooks relationship", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: rec.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + assert {:ok, found} = MapDiscordNotification.by_map(map.id) + refute match?(%Ash.NotLoaded{}, found.webhooks) + assert Enum.map(found.webhooks, & &1.role) |> Enum.sort() == [:character, :system] + end + + test "enforces one notification per map", %{map: map} do + {:ok, _} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:error, _} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + end + + test "deleting the map cascades the notification and its webhooks away", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + {:ok, [hook]} = MapDiscordWebhook.by_notification(rec.id) + + Ash.destroy!(map) + + assert {:error, _} = MapDiscordNotification.by_id(rec.id) + assert {:error, _} = MapDiscordWebhook.by_id(hook.id) + end + + test "destroy invalidates the cache and stops each webhook's worker", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + {:ok, [system_hook]} = MapDiscordWebhook.by_notification(rec.id) + + {:ok, character_hook} = + MapDiscordWebhook.create(%{ + notification_id: rec.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + start_supervised!(WorkerSupervisor) + registry = WorkerSupervisor.registry() + + # Seed the routing cache so the destroy has something to evict โ€” without + # this the test asserts only the worker teardown, and a regression in the + # destroy-side invalidation passes silently despite the test's name. + Cachex.put(@cache, map.id, rec) + + # Register one worker per webhook id โ€” the key `stash_webhook_ids/2` reads + # and `after_destroy/3` stops by. Started directly against the real + # `Worker`/`Registry` (rather than through `WorkerSupervisor.deliver/2`) so + # this proves the actual registry entries this destroy path is responsible + # for clearing. + for webhook <- [system_hook, character_hook] do + start_supervised!( + {Worker, webhook_id: webhook.id, registry: registry, idle_timeout: :infinity}, + id: webhook.id, + restart: :temporary + ) + end + + assert [{_pid, _}] = Registry.lookup(registry, system_hook.id) + assert [{_pid, _}] = Registry.lookup(registry, character_hook.id) + + assert :ok = MapDiscordNotification.destroy(rec) + assert {:error, _} = MapDiscordNotification.by_map(map.id) + assert Cachex.get(@cache, map.id) == {:ok, nil} + + # Registry release on process exit is asynchronous, so poll rather than + # asserting immediately. + await_condition(fn -> + if Registry.lookup(registry, system_hook.id) == [] and + Registry.lookup(registry, character_hook.id) == [] do + {:ok, :done} + else + :retry + end + end) + end + + test "destroy tolerates the worker registry not running at all", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + # No WorkerSupervisor started in this test โ€” the custom destroy must not + # crash just because the delivery infrastructure is down (e.g. webhooks + # globally disabled). + assert :ok = MapDiscordNotification.destroy(rec) + assert {:error, _} = MapDiscordNotification.by_map(map.id) + end + + # `after_action` hooks run inside the action's transaction, in the order they + # were added. This one is appended after the resource's own hooks, so it + # stands in for a killmail that arrives *just after* an `after_action` + # invalidation would have run: `DiscordDispatcher.load_and_cache/1` reads + # pre-commit state, finds no config, and stores the negative `:none` marker. + # + # Only an invalidation that runs after the transaction clears that marker. + # Under `after_action` it survives for the cache's 5-minute default TTL, and + # the map the user just configured posts nothing for five minutes. + defp cache_none_inside_transaction(changeset, map_id) do + Ash.Changeset.after_action(changeset, fn _changeset, record -> + Cachex.put(@cache, map_id, :none) + {:ok, record} + end) + end + + test "create invalidates the cache after the transaction, not inside it", %{map: map} do + assert {:ok, _rec} = + MapDiscordNotification + |> Ash.Changeset.for_create(:create, %{map_id: map.id, webhook_url: valid_url()}) + |> cache_none_inside_transaction(map.id) + |> Ash.create() + + assert Cachex.get(@cache, map.id) == {:ok, nil} + end + + test "update invalidates the cache after the transaction, not inside it", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, _rec} = + rec + |> Ash.Changeset.for_update(:update, %{wh_only: false}) + |> cache_none_inside_transaction(map.id) + |> Ash.update() + + assert Cachex.get(@cache, map.id) == {:ok, nil} + end + + test "a rolled-back create leaves the cached config alone", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + Cachex.put(@cache, map.id, rec) + + # A map with no notification of its own, so the create below can only fail + # on the child's URL validation. Reusing `map` would trip the one-per-map + # identity first and never exercise the rollback path this test is about. + fresh_map = Factory.insert(:map, %{}) + Cachex.put(@cache, fresh_map.id, rec) + + # Rejected by the child's URL validation, so the whole transaction rolls + # back. The after_transaction hook still runs, with an `{:error, _}` result: + # nothing was written, so nothing may be evicted. + assert {:error, _} = + MapDiscordNotification.create(%{ + map_id: fresh_map.id, + webhook_url: "https://evil.example.com/x" + }) + + assert {:ok, ^rec} = Cachex.get(@cache, fresh_map.id) + assert {:ok, ^rec} = Cachex.get(@cache, map.id) + end + + test "route alert fields default off with a 5-jump cap", %{map: map} do + assert {:ok, rec} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert rec.route_alerts_enabled? == false + assert rec.home_system_id == nil + assert rec.route_max_jumps == 5 + end + + test "route alert config round-trips through update", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, updated} = + MapDiscordNotification.update(rec, %{ + route_alerts_enabled?: true, + home_system_id: 30_000_142, + route_max_jumps: 3 + }) + + assert updated.route_alerts_enabled? == true + assert updated.home_system_id == 30_000_142 + assert updated.route_max_jumps == 3 + + assert {:ok, reloaded} = MapDiscordNotification.by_map(map.id) + assert reloaded.home_system_id == 30_000_142 + end + + test "route_alerts_enabled? without a home_system_id is rejected", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + MapDiscordNotification.update(rec, %{route_alerts_enabled?: true}) + + assert Enum.any?(errors, fn e -> + Map.get(e, :field) == :home_system_id and + to_string(Map.get(e, :message, "")) =~ "required" + end) + end + + test "route_alerts_enabled? with a home_system_id already set is accepted", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + {:ok, rec} = MapDiscordNotification.update(rec, %{home_system_id: 30_000_142}) + + assert {:ok, updated} = MapDiscordNotification.update(rec, %{route_alerts_enabled?: true}) + assert updated.route_alerts_enabled? == true + end + + test "home_system_id can be set while route_alerts_enabled? stays false", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, updated} = MapDiscordNotification.update(rec, %{home_system_id: 30_000_142}) + assert updated.route_alerts_enabled? == false + end + + test "route_max_jumps accepts the 1..20 boundary and rejects outside it", %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, _} = MapDiscordNotification.update(rec, %{route_max_jumps: 1}) + assert {:ok, _} = MapDiscordNotification.update(rec, %{route_max_jumps: 20}) + assert {:error, _} = MapDiscordNotification.update(rec, %{route_max_jumps: 0}) + assert {:error, _} = MapDiscordNotification.update(rec, %{route_max_jumps: 21}) + end + + test "updating route alert config invalidates the cache after the transaction, not inside it", + %{map: map} do + {:ok, rec} = MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + assert {:ok, _rec} = + rec + |> Ash.Changeset.for_update(:update, %{ + route_alerts_enabled?: true, + home_system_id: 30_000_142 + }) + |> cache_none_inside_transaction(map.id) + |> Ash.update() + + assert Cachex.get(@cache, map.id) == {:ok, nil} + end +end diff --git a/test/unit/api/map_discord_webhook_test.exs b/test/unit/api/map_discord_webhook_test.exs new file mode 100644 index 000000000..a108bbe01 --- /dev/null +++ b/test/unit/api/map_discord_webhook_test.exs @@ -0,0 +1,544 @@ +defmodule WandererApp.Api.MapDiscordWebhookTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Api.MapDiscordNotification + alias WandererApp.Api.MapDiscordWebhook + alias WandererAppWeb.Factory + + defp valid_url, do: "https://discord.com/api/webhooks/123456789/abcdefTOKEN" + + # The dispatcher's per-map config cache, seeded and read directly so these + # tests do not depend on the dispatcher GenServer running. + @cache :discord_notification_cache + + setup do + map = Factory.insert(:map, %{}) + + {:ok, notification} = + MapDiscordNotification.create(%{map_id: map.id, webhook_url: valid_url()}) + + %{map: map, notification: notification} + end + + test "creates with valid discord url and defaults", %{notification: notification} do + assert {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + assert hook.role == :character + assert hook.webhook_url == valid_url() + assert hook.enabled? == true + assert hook.consecutive_failures == 0 + assert hook.last_error == nil + assert hook.last_error_at == nil + assert hook.last_delivery_at == nil + end + + test "accepts discordapp.com host", %{notification: notification} do + url = "https://discordapp.com/api/webhooks/123/tok" + + assert {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "accepts a versioned webhook path", %{notification: notification} do + url = "https://canary.discord.com/api/v10/webhooks/999/newtok" + + assert {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "rejects non-https scheme", %{notification: notification} do + url = "http://discord.com/api/webhooks/123/tok" + + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "rejects non-discord host", %{notification: notification} do + url = "https://evil.example.com/api/webhooks/123/tok" + + # Assert on the specific validation message, not a bare {:error, _}. A + # blanket-reject regression (e.g. reading the AshCloak attribute instead of + # the argument, which yields %Ash.NotLoaded{}) would satisfy {:error, _} + # while rejecting valid URLs too. + assert {:error, %Ash.Error.Invalid{errors: errors}} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + + assert Enum.any?(errors, fn e -> + Map.get(e, :field) == :webhook_url and + to_string(Map.get(e, :message, "")) =~ "Discord webhook URL" + end) + end + + test "rejects host that merely contains discord.com", %{notification: notification} do + url = "https://discord.com.evil.example/api/webhooks/123/tok" + + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "rejects malformed webhook path", %{notification: notification} do + url = "https://discord.com/api/not-webhooks/123/tok" + + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: url + }) + end + + test "rejects an unknown role", %{notification: notification} do + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :corporation, + webhook_url: valid_url() + }) + end + + test "enforces one webhook per (notification, role)", %{notification: notification} do + # `MapDiscordNotification.create/1` (setup) already creates the :system + # webhook โ€” this asserts a second :system create for the same notification + # is rejected. + assert {:error, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :system, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + end + + test "allows both roles under the same notification", %{notification: notification} do + # `MapDiscordNotification.create/1` (setup) already created the :system + # webhook; only the :character one needs creating here. + assert {:ok, _} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + assert {:ok, hooks} = MapDiscordWebhook.by_notification(notification.id) + assert Enum.map(hooks, & &1.role) |> Enum.sort() == [:character, :system] + end + + test "rejects invalid url on UPDATE as well as create", %{notification: notification} do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + assert {:error, %Ash.Error.Invalid{errors: errors}} = + MapDiscordWebhook.update(hook, %{webhook_url: "https://evil.example.com/x"}) + + assert Enum.any?(errors, fn e -> + Map.get(e, :field) == :webhook_url and + to_string(Map.get(e, :message, "")) =~ "Discord webhook URL" + end) + + # The rejected value must not have been persisted. + {:ok, reloaded} = MapDiscordWebhook.by_id(hook.id) + assert reloaded.webhook_url == valid_url() + end + + test "accepts a valid replacement url on UPDATE", %{notification: notification} do + # Guards against a blanket-reject regression: replacement must still work. + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + replacement = "https://canary.discord.com/api/v10/webhooks/999/newtok" + + assert {:ok, updated} = MapDiscordWebhook.update(hook, %{webhook_url: replacement}) + assert updated.webhook_url == replacement + end + + test "set_enabled toggles only this webhook", %{notification: notification} do + # `MapDiscordNotification.create/1` (setup) already created the :system + # webhook. + {:ok, [sys]} = MapDiscordWebhook.by_notification(notification.id) + + {:ok, char} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + assert {:ok, char} = MapDiscordWebhook.set_enabled(char, %{enabled?: false}) + assert char.enabled? == false + + assert {:ok, sys} = MapDiscordWebhook.by_id(sys.id) + assert sys.enabled? == true + end + + test "destroying the notification cascades the webhooks away", %{notification: notification} do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + :ok = MapDiscordNotification.destroy(notification) + + assert {:error, _} = MapDiscordWebhook.by_id(hook.id) + end + + test "destroy tolerates a cache and worker registry that are not running", %{ + notification: notification + } do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + assert :ok = MapDiscordWebhook.destroy(hook) + assert {:error, _} = MapDiscordWebhook.by_id(hook.id) + end + + defp character_hook(notification) do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + + hook + end + + test "record_failure increments and does not disable before the threshold", %{ + notification: notification + } do + hook = character_hook(notification) + + hook = + Enum.reduce(1..9, hook, fn _, acc -> + {:ok, updated} = MapDiscordWebhook.record_failure(acc, "boom") + updated + end) + + assert hook.consecutive_failures == 9 + assert hook.enabled? == true + assert hook.last_error == "boom" + assert hook.last_error_at != nil + end + + test "record_failure disables at 10 consecutive failures", %{notification: notification} do + hook = character_hook(notification) + + hook = + Enum.reduce(1..10, hook, fn _, acc -> + {:ok, updated} = MapDiscordWebhook.record_failure(acc, "boom") + updated + end) + + assert hook.consecutive_failures == 10 + assert hook.enabled? == false + end + + test "record_failure disables only the failing webhook", %{notification: notification} do + # This is the entire point of the split: before it, ten failures on the + # character channel would have silenced system kills too. + # + # `MapDiscordNotification.create/1` (setup) already created the :system + # webhook. + {:ok, [sys]} = MapDiscordWebhook.by_notification(notification.id) + + {:ok, char} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :character, + webhook_url: "https://discord.com/api/webhooks/222/othertok" + }) + + Enum.reduce(1..10, char, fn _, acc -> + {:ok, updated} = MapDiscordWebhook.record_failure(acc, "boom") + updated + end) + + assert {:ok, sys} = MapDiscordWebhook.by_id(sys.id) + assert sys.enabled? == true + assert sys.consecutive_failures == 0 + end + + test "record_failure re-reads the counter rather than trusting a stale copy", %{ + notification: notification + } do + stale = character_hook(notification) + + # Advance the stored counter behind the back of the `stale` struct. + {:ok, _} = MapDiscordWebhook.record_failure(stale, "first") + + {:ok, updated} = MapDiscordWebhook.record_failure(stale, "second") + + assert updated.consecutive_failures == 2 + assert updated.last_error == "second" + end + + test "record_failure truncates an overlong error", %{notification: notification} do + hook = character_hook(notification) + + {:ok, hook} = MapDiscordWebhook.record_failure(hook, String.duplicate("x", 900)) + + assert String.length(hook.last_error) == 500 + end + + test "record_success clears the failure state", %{notification: notification} do + hook = character_hook(notification) + {:ok, hook} = MapDiscordWebhook.record_failure(hook, "boom") + + {:ok, hook} = MapDiscordWebhook.record_success(hook) + + assert hook.consecutive_failures == 0 + assert hook.last_error == nil + assert hook.last_error_at == nil + assert hook.last_delivery_at != nil + end + + test "disable switches the webhook off immediately", %{notification: notification} do + hook = character_hook(notification) + + {:ok, hook} = MapDiscordWebhook.disable(hook, "404 Not Found") + + assert hook.enabled? == false + assert hook.last_error == "404 Not Found" + assert hook.last_error_at != nil + end + + # `after_action` hooks run inside the action's transaction, in the order they + # were added. This one is appended after the resource's own hooks, so it + # stands in for a killmail that arrives *just after* an `after_action` + # invalidation would have run: `DiscordDispatcher.load_and_cache/1` reads + # pre-commit state and re-caches it โ€” the pre-update URL, or the negative + # `:none` marker. Only an invalidation that runs after the transaction clears + # that; under `after_action` the stale entry survives the cache's 5-minute + # default TTL, so the destination the user just changed keeps posting nowhere + # (or to the old channel). + defp cache_none_inside_transaction(changeset, map_id) do + Ash.Changeset.after_action(changeset, fn _changeset, record -> + Cachex.put(@cache, map_id, :none) + {:ok, record} + end) + end + + test "create invalidates the cache after the transaction, not inside it", %{ + map: map, + notification: notification + } do + assert {:ok, _hook} = + MapDiscordWebhook + |> Ash.Changeset.for_create(:create, %{ + notification_id: notification.id, + role: :character, + webhook_url: valid_url() + }) + |> cache_none_inside_transaction(map.id) + |> Ash.create() + + assert Cachex.get(@cache, map.id) == {:ok, nil} + end + + test "every health-updating action invalidates the cache after the transaction", %{ + map: map, + notification: notification + } do + hook_id = character_hook(notification).id + + for {action, params} <- [ + {:update, %{webhook_url: "https://canary.discord.com/api/v10/webhooks/999/newtok"}}, + {:set_enabled, %{enabled?: false}}, + {:record_failure, %{error: "boom"}}, + {:disable, %{error: "404 Not Found"}} + ] do + {:ok, hook} = MapDiscordWebhook.by_id(hook_id) + + assert {:ok, _} = + hook + |> Ash.Changeset.for_update(action, params) + |> cache_none_inside_transaction(map.id) + |> Ash.update() + + assert Cachex.get(@cache, map.id) == {:ok, nil}, + "#{action} left the pre-commit cache entry in place" + end + end + + test "a rejected update leaves the cached config alone", %{ + map: map, + notification: notification + } do + hook = character_hook(notification) + Cachex.put(@cache, map.id, notification) + + assert {:error, _} = + MapDiscordWebhook.update(hook, %{webhook_url: "https://evil.example.com/x"}) + + # The after_transaction hook runs on failure too, with an `{:error, _}` + # result: nothing was written, so nothing may be evicted. + assert {:ok, ^notification} = Cachex.get(@cache, map.id) + end + + describe "valid_webhook_url?/1" do + test "accepts a Discord host regardless of case" do + # URI.parse/1 returns the host as typed, and users paste URLs, so a + # capitalised host must not be mistaken for a non-Discord one. + for host <- ~w(discord.com Discord.com DISCORD.COM ptb.Discord.com CANARY.discord.com) do + url = "https://#{host}/api/webhooks/123456789/abcdefTOKEN" + assert MapDiscordWebhook.valid_webhook_url?(url), "rejected #{url}" + end + end + + test "still rejects a lookalike host" do + refute MapDiscordWebhook.valid_webhook_url?( + "https://discord.com.evil.example/api/webhooks/1/t" + ) + + refute MapDiscordWebhook.valid_webhook_url?("https://Evil.example/api/webhooks/1/t") + end + end + + test "update cannot re-parent a webhook onto another notification", %{ + notification: notification + } do + hook = character_hook(notification) + + {:ok, other_notification} = other_map_with_notification() + + # `notification_id` is outside the update action's accept list: moving a + # webhook would carry the credential onto another map, and `do_invalidate/1` + # resolves the notification *after* the write, so the original map would + # keep routing to a destination it no longer owns for the rest of the TTL. + assert {:error, error} = + MapDiscordWebhook.update(hook, %{notification_id: other_notification.id}) + + assert Exception.message(error) =~ "notification_id" + + {:ok, reloaded} = MapDiscordWebhook.by_id(hook.id) + assert reloaded.notification_id == notification.id + end + + defp other_map_with_notification do + other_map = Factory.insert(:map, %{}) + + WandererApp.Api.MapDiscordNotification.create(%{ + map_id: other_map.id, + webhook_url: "https://discord.com/api/webhooks/999/othertok" + }) + end + + test "accepts the :route role", %{notification: notification} do + assert {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: valid_url() + }) + + assert hook.role == :route + assert hook.mention_targets == [] + end + + test "mention_targets accepts well-formed user and role snowflakes", %{ + notification: notification + } do + assert {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: valid_url(), + mention_targets: ["user:123456789012345678", "role:98765432109876543"] + }) + + assert hook.mention_targets == ["user:123456789012345678", "role:98765432109876543"] + end + + test "mention_targets rejects a handle, a bare id, and an out-of-range snowflake", %{ + notification: notification + } do + for bad <- ["@guarzo", "user:123", "role:123456789012345678901", "corp:123456789012345678"] do + assert {:error, %Ash.Error.Invalid{}} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: valid_url(), + mention_targets: [bad] + }), + "expected #{inspect(bad)} to be rejected" + end + end + + test "mention_targets round-trips through update", %{notification: notification} do + {:ok, hook} = + MapDiscordWebhook.create(%{ + notification_id: notification.id, + role: :route, + webhook_url: valid_url() + }) + + assert {:ok, updated} = + MapDiscordWebhook.update(hook, %{mention_targets: ["role:112233445566778899"]}) + + assert updated.mention_targets == ["role:112233445566778899"] + end + + test "mention_targets validation rejects a malformed target via Mentions.valid_target?/1" do + # Same rejection as before the fold โ€” this asserts the behaviour survives + # the delegation, and the assertion below pins that there is now exactly + # one regex literal for mention targets in lib/. + assert WandererApp.ExternalEvents.Discord.Mentions.valid_target?("role:123456789012345678") + refute WandererApp.ExternalEvents.Discord.Mentions.valid_target?("role:123") + + resource_source = + File.read!("lib/wanderer_app/api/map_discord_webhook.ex") + + # Spelling-agnostic on purpose: pinning one literal (`~r/^(user|role):`) + # stopped catching a re-duplication the moment `Mentions` switched its + # anchors to `\A`/`\z`. Any regex construction naming both prefixes is a + # second definition, however its anchors are written. + duplicated_regex = + ~r/(~r|Regex\.compile!?)/ + |> Regex.split(resource_source, trim: true) + |> Enum.drop(1) + |> Enum.any?(fn following -> + head = String.slice(following, 0, 120) + head =~ "user" and head =~ "role" + end) + + refute duplicated_regex, + "ValidateMentionTargets must delegate to Mentions.valid_target?/1, not carry its own regex" + end +end diff --git a/test/unit/api/map_scopes_default_test.exs b/test/unit/api/map_scopes_default_test.exs new file mode 100644 index 000000000..8c96b22b3 --- /dev/null +++ b/test/unit/api/map_scopes_default_test.exs @@ -0,0 +1,33 @@ +defmodule WandererApp.Api.MapScopesDefaultTest do + use WandererApp.DataCase, async: false + + alias WandererApp.Repo + alias WandererAppWeb.Factory + + # `Api.Map` supplies `default([:wormholes])`, so anything written through Ash + # never reads the column default. Inserts that bypass Ash do - and one of + # them, `SlugRecoveryTest`'s `insert_map_directly/4`, failed for months + # because of it: `20260331192521` and `20260406213852` both set the default + # with the charlist `~c"{wormholes}"`, which Postgres stored as the eleven + # code points of the string. Reading such a row back through Ash blew up + # casting `'123'` into the `{:array, :atom}` `one_of` constraint, and + # `get_map_by_slug_safely/1` surfaced it as `{:error, :unknown_error}`. + test "a map inserted without scopes gets [:wormholes], not the charlist code points" do + owner = Factory.insert(:character, %{}) + + {:ok, result} = + Repo.query( + """ + INSERT INTO maps_v1 (id, slug, name, owner_id, deleted, scope, inserted_at, updated_at) + VALUES (gen_random_uuid(), $1, $2, $3, false, 'wormholes', NOW(), NOW()) + RETURNING id + """, + ["scopes-default-test", "Scopes Default", Ecto.UUID.dump!(owner.id)] + ) + + [[id]] = result.rows + + assert {:ok, map} = WandererApp.Api.Map.by_id(Ecto.UUID.load!(id)) + assert map.scopes == [:wormholes] + end +end diff --git a/test/unit/cache_options_test.exs b/test/unit/cache_options_test.exs new file mode 100644 index 000000000..648415a3d --- /dev/null +++ b/test/unit/cache_options_test.exs @@ -0,0 +1,65 @@ +defmodule WandererApp.CacheOptionsTest do + @moduledoc """ + Guards against Cachex options that do nothing. + + Every cache in `application.ex` was declared with `default_ttl:`, which is a + Cachex 2.x option. On 3.x it is not recognised, and Cachex ignores options it + does not recognise rather than rejecting them โ€” so fifteen caches claimed an + expiry policy in source, applied none at runtime, and nothing anywhere said + so. Two comments elsewhere in the tree had been written reasoning from those + TTLs as if they were real. + + This is a source-level test on purpose. A runtime assertion would have to + start every cache to check a property that is fully determined by the child + spec, and a dead option is dead whether or not the cache is ever started. + """ + + use ExUnit.Case, async: true + + @application_path "lib/wanderer_app/application.ex" + + # Options accepted by Cachex 2.x and silently dropped by 3.x, mapped to the + # 3.x spelling. `grep` for the option name is enough: these appear only in + # cache child specs. + @dead_cachex_options %{ + "default_ttl" => "expiration: expiration(default: ...)", + "ttl_interval" => "expiration: expiration(interval: ...)", + "default_fallback" => "fallback: fallback(default: ...)", + "record_stats" => "stats: true" + } + + test "no cache is declared with a Cachex 2.x option" do + source = code_without_comments(@application_path) + + found = + @dead_cachex_options + |> Enum.filter(fn {option, _replacement} -> + Regex.match?(~r/\b#{option}:/, source) + end) + + assert found == [], + """ + #{@application_path} declares Cachex options that Cachex 3.x ignores: + + #{Enum.map_join(found, "\n", fn {option, replacement} -> " #{option}: -> #{replacement}" end)} + + Cachex 3 drops unrecognised options without complaining, so the + option reads as configuration and behaves as a comment. Either use + the 3.x spelling โ€” which turns real expiry on, so decide that + deliberately โ€” or remove the option. + """ + end + + # Whole-line comments are dropped before matching: the file documents these + # option names in prose right above the cache list, and prose naming a dead + # option is the opposite of the problem. Only full-line comments are stripped, + # so a trailing `# default_ttl: ...` on a code line would still trip the test + # โ€” acceptable, since the point is to catch the option in a child spec. + defp code_without_comments(path) do + path + |> File.read!() + |> String.split("\n") + |> Enum.reject(&Regex.match?(~r/^\s*#/, &1)) + |> Enum.join("\n") + end +end diff --git a/test/unit/cached_info_static_info_test.exs b/test/unit/cached_info_static_info_test.exs new file mode 100644 index 000000000..759bcf60c --- /dev/null +++ b/test/unit/cached_info_static_info_test.exs @@ -0,0 +1,100 @@ +defmodule WandererApp.CachedInfoStaticInfoTest do + use WandererApp.DataCase, async: false + + alias WandererApp.CachedInfo + + @repo_query_event [:wanderer_app, :repo, :query] + @solar_system_table "map_solar_system_v2" + + defp unique_system_id, do: 31_000_000 + System.unique_integer([:positive]) + + defp create_uncached_system do + system = create_solar_system(%{solar_system_id: unique_system_id()}) + # The cache is process-global and survives across tests, so a system created + # here could already have been pulled in by an earlier warm-up. + Cachex.del(:system_static_info_cache, system.solar_system_id) + on_exit(fn -> Cachex.del(:system_static_info_cache, system.solar_system_id) end) + system + end + + # Counts only full reads of the solar system table, which is what the warm-up + # issues โ€” inserts from the factory and unrelated queries are filtered out. + defp count_table_scans(fun) do + {:ok, counter} = Agent.start_link(fn -> 0 end) + handler_id = {__MODULE__, System.unique_integer([:positive])} + + :telemetry.attach( + handler_id, + @repo_query_event, + fn _event, _measurements, metadata, _config -> + if metadata[:source] == @solar_system_table and + String.starts_with?(metadata[:query] || "", "SELECT") do + Agent.update(counter, &(&1 + 1)) + end + end, + nil + ) + + try do + result = fun.() + {result, Agent.get(counter, & &1)} + after + :telemetry.detach(handler_id) + Agent.stop(counter) + end + end + + # A single cache miss repopulates the entire table, so before the Courier + # guard N concurrent misses each ran their own full scan and row-by-row + # rewrite. Route hydration fans out over every system in a route at + # `schedulers_online() * 4`, and the cache has a 4h TTL, so this fired on every + # restart and every TTL rollover โ€” and each redundant scan is what made an + # individual lookup slow enough to blow its `Task.async_stream` budget. + test "concurrent cache misses collapse into a single table scan" do + systems = Enum.map(1..25, fn _ -> create_uncached_system() end) + + {results, scans} = + count_table_scans(fn -> + systems + |> Enum.map(fn system -> + Task.async(fn -> CachedInfo.get_system_static_info(system.solar_system_id) end) + end) + |> Task.await_many(15_000) + end) + + # Every caller still gets its own system back, not just the one that won. + assert length(results) == 25 + + Enum.zip(systems, results) + |> Enum.each(fn {system, result} -> + assert {:ok, %{solar_system_id: id}} = result + assert id == system.solar_system_id + end) + + assert scans == 1 + end + + # The guard is `{:ignore, _}`, so nothing is written to mark the cache "warm". + # That matters: a marker with a TTL would make a system inserted just after a + # warm-up invisible until it expired. Sequential behaviour must be unchanged. + test "a later miss still triggers a fresh scan and sees a newly added system" do + first = create_uncached_system() + assert {:ok, %{solar_system_id: _}} = CachedInfo.get_system_static_info(first.solar_system_id) + + second = create_uncached_system() + + {result, scans} = + count_table_scans(fn -> CachedInfo.get_system_static_info(second.solar_system_id) end) + + assert {:ok, %{solar_system_id: id}} = result + assert id == second.solar_system_id + assert scans == 1 + end + + # `{:error, :not_found}` rather than a crash or a stale `{:ok, nil}` โ€” the + # route hydrator relies on this clause to drop the system instead of taking + # the whole request down with a CaseClauseError. + test "an id with no row still resolves to :not_found after the warm-up" do + assert {:error, :not_found} = CachedInfo.get_system_static_info(unique_system_id()) + end +end diff --git a/test/unit/character/tracker_update_settings_test.exs b/test/unit/character/tracker_update_settings_test.exs new file mode 100644 index 000000000..bd176d6a0 --- /dev/null +++ b/test/unit/character/tracker_update_settings_test.exs @@ -0,0 +1,434 @@ +defmodule WandererApp.Character.TrackerUpdateSettingsTest do + @moduledoc """ + Regression tests for `WandererApp.Character.Tracker.update_settings/2`. + + Production incident: characters were present on a map with `tracked: true` in + the database, yet the map never updated as they moved through systems. + + Root cause: `TrackingUtils.track_character/4` starts map tracking by calling + `update_track_settings(character_id, %{map_id: map_id, track: true})`. That + settings map carries no `track_location` key, so `maybe_start_location_tracking/2` + never matched and `track_location` stayed `false`. `update_location/1` only + matches `%{track_location: true, is_online: true}`, so location polling was + silently skipped for the character's entire session. + + The only other writer of `track_location` is `update_online/1`, which is gated + on an online-status *transition*. A character already online when map tracking + begins never produces that transition, so the flag stayed `false` indefinitely. + """ + + use ExUnit.Case, async: false + + alias WandererApp.Character.Tracker + + setup do + character_id = "test-char-#{System.unique_integer([:positive])}" + map_id = "test-map-#{System.unique_integer([:positive])}" + + on_exit(fn -> + Cachex.del(:character_state_cache, character_id) + WandererApp.Cache.delete("character:#{character_id}:map:#{map_id}:tracking_start_time") + end) + + %{character_id: character_id, map_id: map_id} + end + + defp seed_state(character_id, overrides) do + state = + Tracker.new(%{character_id: character_id}) + |> Map.merge(overrides) + + Cachex.put(:character_state_cache, character_id, state) + state + end + + describe "update_settings/2 when map tracking starts" do + test "enables location tracking for a character who is already online", %{ + character_id: character_id, + map_id: map_id + } do + # Exact state observed in production: the character is online in EVE and + # online tracking is active, but location tracking was previously cleared + # by maybe_stop_tracking/2 when they left all maps. + seed_state(character_id, %{ + is_online: true, + track_online: true, + track_location: false, + track_ship: false, + active_maps: [] + }) + + {:ok, state} = Tracker.update_settings(character_id, %{map_id: map_id, track: true}) + + assert map_id in state.active_maps, + "map should be registered as active" + + assert state.track_location, + """ + track_location must be enabled when map tracking starts. + + While false, update_location/1 falls through to its catch-all clause + and returns {:error, :skipped} silently, so the character's location + is never fetched from ESI and the map never updates as they move. + """ + end + + test "enables ship tracking for a character who is already online", %{ + character_id: character_id, + map_id: map_id + } do + seed_state(character_id, %{ + is_online: true, + track_online: true, + track_location: false, + track_ship: false, + active_maps: [] + }) + + {:ok, state} = Tracker.update_settings(character_id, %{map_id: map_id, track: true}) + + assert state.track_ship, + "track_ship must be enabled when map tracking starts, for the same reason" + end + + test "enables location tracking even when the character is currently offline", %{ + character_id: character_id, + map_id: map_id + } do + # track_location is an intent flag, not a liveness flag. update_location/1 + # independently requires is_online: true, so setting it while offline is + # safe and avoids depending on an online transition that may never come. + seed_state(character_id, %{ + is_online: false, + track_online: true, + track_location: false, + track_ship: false, + active_maps: [] + }) + + {:ok, state} = Tracker.update_settings(character_id, %{map_id: map_id, track: true}) + + assert state.track_location + end + + test "is idempotent when tracking is already active for the map", %{ + character_id: character_id, + map_id: map_id + } do + seed_state(character_id, %{ + is_online: true, + track_online: true, + track_location: true, + track_ship: true, + active_maps: [map_id] + }) + + {:ok, state} = Tracker.update_settings(character_id, %{map_id: map_id, track: true}) + + assert state.track_location + assert state.track_ship + assert state.active_maps == [map_id], "map should not be duplicated in active_maps" + end + end + + describe "update_settings/2 when map tracking stops" do + test "clears location and ship tracking once no maps remain active", %{ + character_id: character_id, + map_id: map_id + } do + seed_state(character_id, %{ + is_online: true, + track_online: true, + track_location: true, + track_ship: true, + active_maps: [map_id] + }) + + {:ok, state} = Tracker.update_settings(character_id, %{map_id: map_id, track: false}) + + refute state.track_location + refute state.track_ship + end + end + + describe "update_location/1 diagnostics" do + test "warns when an online character on a map has location tracking disabled", %{ + character_id: character_id, + map_id: map_id + } do + on_exit(fn -> + WandererApp.Cache.delete("character:#{character_id}:location_skip_logged") + end) + + state = + Tracker.new(%{character_id: character_id}) + |> Map.merge(%{ + is_online: true, + track_location: false, + active_maps: [map_id] + }) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert {:error, :skipped} = Tracker.update_location(state) + end) + + assert log =~ "update_location skipped for online character #{character_id}" + assert log =~ "track_location=false" + end + + test "does not warn for an offline character, which is expected", %{ + character_id: character_id, + map_id: map_id + } do + state = + Tracker.new(%{character_id: character_id}) + |> Map.merge(%{ + is_online: false, + track_location: false, + active_maps: [map_id] + }) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert {:error, :skipped} = Tracker.update_location(state) + end) + + refute log =~ "update_location skipped" + end + + test "throttles the warning to once per character", %{ + character_id: character_id, + map_id: map_id + } do + on_exit(fn -> + WandererApp.Cache.delete("character:#{character_id}:location_skip_logged") + end) + + state = + Tracker.new(%{character_id: character_id}) + |> Map.merge(%{ + is_online: true, + track_location: false, + active_maps: [map_id] + }) + + ExUnit.CaptureLog.capture_log(fn -> Tracker.update_location(state) end) + + # update_location/1 runs on a per-second tick; an unthrottled warning here + # would bury the log it is meant to make visible. + second_log = + ExUnit.CaptureLog.capture_log(fn -> + assert {:error, :skipped} = Tracker.update_location(state) + end) + + refute second_log =~ "update_location skipped" + end + end + + describe "defect instrumentation" do + setup %{character_id: character_id} do + events = [ + [:wanderer_app, :character, :tracking, :location_flag_cleared], + [:wanderer_app, :character, :tracking, :location_flag_repaired], + [:wanderer_app, :character, :tracking, :location_skipped_while_active] + ] + + handler_id = "test-handler-#{character_id}" + test_pid = self() + + :telemetry.attach_many( + handler_id, + events, + fn event, measurements, metadata, _ -> + send(test_pid, {:telemetry, event, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + :ok + end + + test "emits :location_flag_repaired only for a character who would have frozen", %{ + character_id: character_id, + map_id: map_id + } do + seed_state(character_id, %{ + is_online: true, + track_location: false, + active_maps: [] + }) + + {:ok, _state} = Tracker.update_settings(character_id, %{map_id: map_id, track: true}) + + assert_receive {:telemetry, [:wanderer_app, :character, :tracking, :location_flag_repaired], + %{count: 1}, %{character_id: ^character_id}} + end + + test "does not emit :location_flag_repaired on a normal fresh-tracker start", %{ + character_id: character_id, + map_id: map_id + } do + # A new tracker defaults to is_online: false and picks up location tracking + # via update_online/1's transition. That is the ordinary path, not a repair, + # and counting it would drown the signal this metric exists to carry. + seed_state(character_id, %{ + is_online: false, + track_location: false, + active_maps: [] + }) + + {:ok, _state} = Tracker.update_settings(character_id, %{map_id: map_id, track: true}) + + refute_receive {:telemetry, [:wanderer_app, :character, :tracking, :location_flag_repaired], + _, _} + end + + test "does not emit :location_flag_repaired when tracking is already healthy", %{ + character_id: character_id, + map_id: map_id + } do + seed_state(character_id, %{ + is_online: true, + track_location: true, + active_maps: [map_id] + }) + + {:ok, _state} = Tracker.update_settings(character_id, %{map_id: map_id, track: true}) + + refute_receive {:telemetry, [:wanderer_app, :character, :tracking, :location_flag_repaired], + _, _} + end + + test "emits :location_flag_cleared when an online character leaves their last map", %{ + character_id: character_id, + map_id: map_id + } do + seed_state(character_id, %{ + is_online: true, + track_location: true, + active_maps: [map_id] + }) + + {:ok, _state} = Tracker.update_settings(character_id, %{map_id: map_id, track: false}) + + assert_receive {:telemetry, [:wanderer_app, :character, :tracking, :location_flag_cleared], + %{count: 1}, %{character_id: ^character_id}} + end + + test "does not emit :location_flag_cleared for an offline character", %{ + character_id: character_id, + map_id: map_id + } do + # An offline character's cleared flag is restored by update_online/1 on the + # next online transition, so it never becomes the stuck pair. + seed_state(character_id, %{ + is_online: false, + track_location: true, + active_maps: [map_id] + }) + + {:ok, _state} = Tracker.update_settings(character_id, %{map_id: map_id, track: false}) + + refute_receive {:telemetry, [:wanderer_app, :character, :tracking, :location_flag_cleared], + _, _} + end + + test "does not emit :location_flag_cleared when the flag was already false", %{ + character_id: character_id, + map_id: map_id + } do + # A repeat untrack clears nothing. Counting it would measure calls to + # maybe_stop_tracking/2 rather than transitions into the stuck state, and + # inflate this metric against :location_flag_repaired. + seed_state(character_id, %{ + is_online: true, + track_location: false, + active_maps: [] + }) + + {:ok, _state} = Tracker.update_settings(character_id, %{map_id: map_id, track: false}) + + refute_receive {:telemetry, [:wanderer_app, :character, :tracking, :location_flag_cleared], + _, _} + end + + test "emits :location_skipped_while_active alongside the stuck-state warning", %{ + character_id: character_id, + map_id: map_id + } do + on_exit(fn -> + WandererApp.Cache.delete("character:#{character_id}:location_skip_logged") + end) + + state = + Tracker.new(%{character_id: character_id}) + |> Map.merge(%{is_online: true, track_location: false, active_maps: [map_id]}) + + ExUnit.CaptureLog.capture_log(fn -> Tracker.update_location(state) end) + + assert_receive {:telemetry, + [:wanderer_app, :character, :tracking, :location_skipped_while_active], + %{count: 1}, %{character_id: ^character_id}} + + # The counter lives inside the log throttle on purpose: update_location/1 + # runs on a per-second tick, so an unthrottled emit would measure ticks + # rather than incidents. Without this second call, moving :telemetry.execute + # outside the Cache.put_new guard would still pass. + ExUnit.CaptureLog.capture_log(fn -> Tracker.update_location(state) end) + + refute_receive {:telemetry, + [:wanderer_app, :character, :tracking, :location_skipped_while_active], _, + _} + end + + # The reason is a Prometheus label. prom_ex_plugin.ex declares exactly + # [:presence_driven, :acl_revoked, :manual_untrack, :unknown]; an atom + # outside that set would materialise a series PromEx never declared, and + # since reasons originate from atoms in code the damage is a permanently + # mislabelled series rather than unbounded growth. Bounding it here โ€” at the + # point the label is produced โ€” covers every caller of update_settings/2, + # including the ones that bypass CharactersImpl.untrack_characters/3. + test "carries a recognised untrack reason through to the cleared event", %{ + character_id: character_id, + map_id: map_id + } do + seed_state(character_id, %{is_online: true, track_location: true, active_maps: [map_id]}) + + {:ok, state} = + Tracker.update_settings(character_id, %{ + map_id: map_id, + track: false, + untrack_reason: :acl_revoked + }) + + assert_receive {:telemetry, [:wanderer_app, :character, :tracking, :location_flag_cleared], + _, %{reason: :acl_revoked}} + + assert state.last_cleared_reason == :acl_revoked + end + + test "degrades an unrecognised untrack reason to :unknown rather than raising", %{ + character_id: character_id, + map_id: map_id + } do + seed_state(character_id, %{is_online: true, track_location: true, active_maps: [map_id]}) + + # Raising here would turn a cardinality slip into the very freeze this + # instrumentation exists to detect: the character's tracker would crash + # mid-untrack instead of recording why its location flag was cleared. + {:ok, state} = + Tracker.update_settings(character_id, %{ + map_id: map_id, + track: false, + untrack_reason: :something_a_future_caller_invented + }) + + assert_receive {:telemetry, [:wanderer_app, :character, :tracking, :location_flag_cleared], + _, %{reason: :unknown}} + + assert state.last_cleared_reason == :unknown + end + end +end diff --git a/test/unit/components/live_select_compact_test.exs b/test/unit/components/live_select_compact_test.exs new file mode 100644 index 000000000..eb3e91643 --- /dev/null +++ b/test/unit/components/live_select_compact_test.exs @@ -0,0 +1,87 @@ +defmodule WandererAppWeb.CoreComponents.LiveSelectCompactTest do + use ExUnit.Case, async: true + import Phoenix.LiveViewTest + + defp render_field(opts) do + form = Phoenix.Component.to_form(%{"pick" => nil}, as: :f) + + assigns = + Enum.into(opts, %{ + field: form[:pick], + mode: :single, + options: [], + update_min_len: 1, + debounce: 100 + }) + + render_component(&WandererAppWeb.CoreComponents.live_select/1, assigns) + end + + test "compact drops both label spacer rows and the always-empty tags container" do + default = render_field([]) + compact = render_field(compact: true) + + # Baseline: the default rendering has all three. + assert default =~ ~s(class="label") + assert default =~ "flex flex-wrap gap-1 p-1" + + # Compact has none of them. + refute compact =~ ~s(class="label") + refute compact =~ "flex flex-wrap gap-1 p-1" + + # The tags container element still exists, just with no padding class. + assert compact =~ ~s(class="hidden") + + # And the input itself is untouched. + assert compact =~ "p-autocomplete-input" + end + + test "compact does not suppress the tags container in tag modes" do + compact_tags = render_field(compact: true, mode: :tags) + + assert compact_tags =~ "flex flex-wrap gap-1 p-1" + refute compact_tags =~ ~s(class="hidden") + end + + test "an unlabelled default still renders the spacer rows it always did" do + # Every other call site in the app omits `compact`, so this is the + # regression guard for them. What it no longer asserts is the literal + # `for="form_description"`: that was a `for` attribute on a
      , pointing + # at an id that exists nowhere in the app. It contributed nothing but a + # copy-paste artifact, and keeping it pinned here would have blocked giving + # these comboboxes a real label. + default = render_field([]) + + assert default =~ ~s(
      ) + assert default =~ ~s() + refute default =~ "form_description" + end + + # The bug: `:label` was accepted, stripped from the opts forwarded to + # LiveSelect, and then never rendered โ€” so every combobox in the app was + # announced as "combobox, blank". `for` has to target LiveSelect's own text + # input, whose id it derives as `_text_input`, NOT the `id` we pass + # (that lands on the LiveComponent wrapper). + test "a label is rendered and associated with the text input LiveSelect actually creates" do + labelled = render_field(label: "Home system") + + assert labelled =~ ~s() + end + + # `compact` exists to make the wrapper exactly as tall as its input, so a + # neighbouring Add button lines up with the field. A visible label row would + # undo that, but a missing accessible name is not an acceptable price for it. + test "a compact label is present for assistive tech but takes no vertical space" do + compact = render_field(compact: true, label: "Exclude a system") + + assert compact =~ ~s(